From 9a44a64f11c47c83c7eb2aa5cf60e0138e24d877 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 13:32:00 +0800 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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); From 7d9a5d61123269ad4e7df500cb9d1794e6095738 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 16:26:30 +0800 Subject: [PATCH 24/33] feat: compose MCP and plugin runtime resources --- apps/server/README.md | 4 +- apps/server/src/default-runtime.ts | 24 +- apps/server/src/runtime-composition.test.ts | 108 +++++- apps/server/src/runtime-composition.ts | 307 +++++++++++++++++- apps/server/src/runtime-executor.test.ts | 66 +++- apps/server/src/runtime-executor.ts | 54 ++- docs/CODEX_ALIGNMENT_PLAN.md | 7 +- docs/design/app-server-v1.md | 9 +- docs/design/plugin-security.md | 4 +- docs/milestones/M5.md | 2 +- packages/core/package.json | 12 + packages/core/src/mcp/client.ts | 229 +++++++------ packages/core/src/mcp/oauth.test.ts | 8 + packages/core/src/mcp/oauth.ts | 15 +- packages/core/src/plugins/manifest.test.ts | 36 ++ packages/core/src/plugins/manifest.ts | 90 +++-- .../core/src/plugins/runtime/subprocess.ts | 31 +- packages/core/src/plugins/wireup.test.ts | 40 ++- packages/core/src/plugins/wireup.ts | 117 ++++--- 19 files changed, 932 insertions(+), 231 deletions(-) diff --git a/apps/server/README.md b/apps/server/README.md index 22ba93f..d89ec74 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -26,4 +26,6 @@ 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. +hook. Trusted plugin contributions and MCP servers are composed in that lease: eager/deferred tools +share the host registry, MCP resource references are expanded before the model call, startup/resource +failures become value-free turn diagnostics, and every subprocess/connection closes in `finally`. diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 143cc6a..93ea788 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -33,14 +33,23 @@ export function createDefaultTurnExecutor( 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', ); } - const composition = await composeRuntime({ cwd, directory: home, settings }); + const provider = new DeepSeekProvider({ + apiKey: credentials.apiKey ?? '', + authToken: credentials.authToken, + baseURL: credentials.baseURL ?? settings.baseURL, + }); + const composition = await composeRuntime({ + cwd, + directory: home, + settings, + mode: effectiveMode, + provider, + requestApproval: context.requestApproval, + signal: context.signal, + }); return { host: new RuntimeHost({ - provider: new DeepSeekProvider({ - apiKey: credentials.apiKey ?? '', - authToken: credentials.authToken, - baseURL: credentials.baseURL ?? settings.baseURL, - }), + provider, tools: composition.tools, cwd, mode: effectiveMode, @@ -53,6 +62,9 @@ export function createDefaultTurnExecutor( systemPrompt: composition.systemPrompt, model: composition.model, effort: composition.effort, + diagnostics: composition.diagnostics, + prepareUserMessage: composition.prepareUserMessage, + close: composition.close, }; }, sessionManager, diff --git a/apps/server/src/runtime-composition.test.ts b/apps/server/src/runtime-composition.test.ts index 8f52758..ade68b9 100644 --- a/apps/server/src/runtime-composition.test.ts +++ b/apps/server/src/runtime-composition.test.ts @@ -1,10 +1,18 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { HookDispatcher } from '@deepcode/core/hooks'; +import type { McpClientHandle } from '@deepcode/core/mcp'; +import type { ToolHandler } from '@deepcode/core/tools'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { composeRuntime, resolveComposedMode } from './runtime-composition.js'; +import { + buildPluginCapabilityBridge, + composeRuntime, + resolveComposedMode, + type RuntimeCompositionServices, +} from './runtime-composition.js'; const roots: string[] = []; @@ -69,5 +77,99 @@ describe('composeRuntime', () => { ).resolves.toEqual( expect.objectContaining({ stdout: expect.stringContaining('hook context') }), ); + await composition.close(); + }); + + it('registers eager and deferred MCP tools, expands resources, and closes every lease', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dc-composition-mcp-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-composition-mcp-cwd-')); + roots.push(directory, cwd); + const tool = (name: string): ToolHandler => ({ + name, + definition: { name, description: `${name} description`, inputSchema: { type: 'object' } }, + execute: async () => ({ content: name }), + }); + const eager = tool('mcp__eager__read'); + const deferred = tool('mcp__deferred__search'); + const handle = (serverName: string, tools: ToolHandler[]) => + ({ serverName, tools, resources: [], resourceTemplates: [], prompts: [] }) as McpClientHandle; + const closeMcp = vi.fn(async () => undefined); + const shutdownPlugins = vi.fn(async () => undefined); + const services: Partial = { + collectPluginContributions: async () => ({ + dirs: [join(directory, 'plugins', 'demo')], + mcpServers: {}, + }), + connectAllMcpServers: async () => ({ + handles: [handle('eager', [eager]), handle('deferred', [deferred])], + errors: [{ serverName: 'broken', error: 'offline' }], + }), + closeAllMcpServers: closeMcp, + expandMcpResourceRefs: async () => ({ + text: 'expanded resource', + resolved: [], + errors: [ + { + ref: { raw: '@eager:file://x', server: 'eager', uri: 'file://x' }, + error: 'missing', + }, + ], + }), + wirePlugins: async () => ({ + plugins: [], + hashMismatches: ['demo: hash drift'], + spawnFailures: ['demo'], + shutdown: shutdownPlugins, + }), + }; + + const composition = await composeRuntime({ + cwd, + directory, + settings: { + mcpServers: { + eager: { command: 'eager' }, + deferred: { command: 'deferred', alwaysLoad: false }, + }, + }, + services, + }); + + expect(composition.tools.get(eager.name)).toBe(eager); + expect(composition.tools.get(deferred.name)).toBeUndefined(); + expect(composition.tools.get('ToolSearch')).toBeDefined(); + expect(composition.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'mcp_connect_failed', + 'plugin_hash_mismatch', + 'plugin_start_failed', + ]); + await expect(composition.prepareUserMessage('read it')).resolves.toEqual({ + text: 'expanded resource', + diagnostics: [expect.objectContaining({ code: 'mcp_resource_failed' })], + }); + await composition.close(); + await composition.close(); + expect(closeMcp).toHaveBeenCalledOnce(); + expect(shutdownPlugins).toHaveBeenCalledOnce(); + }); + + it('gates plugin subprocess capabilities through mode and approval policy', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-plugin-bridge-cwd-')); + roots.push(cwd); + const target = join(cwd, 'plugin.txt'); + const hooks = new HookDispatcher({}); + const denied = buildPluginCapabilityBridge({ cwd, mode: 'plan', hooks }); + await expect(denied.fs_write(target, 'blocked')).rejects.toThrow(/mode=plan/); + + const requestApproval = vi.fn(async () => 'allow' as const); + const allowed = buildPluginCapabilityBridge({ + cwd, + mode: 'default', + hooks, + requestApproval, + }); + await allowed.fs_write(target, 'allowed'); + expect(await readFile(target, 'utf8')).toBe('allowed'); + expect(requestApproval).toHaveBeenCalledWith('Write', expect.any(String)); }); }); diff --git a/apps/server/src/runtime-composition.ts b/apps/server/src/runtime-composition.ts index b4d4fa2..86753ee 100644 --- a/apps/server/src/runtime-composition.ts +++ b/apps/server/src/runtime-composition.ts @@ -1,21 +1,81 @@ -import type { Effort, Mode } from '@deepcode/core'; -import type { DeepCodeSettings } from '@deepcode/core/config'; +import type { Effort, Mode, Provider } from '@deepcode/core'; +import type { + DeepCodeSettings, + McpServerConfig, + PermissionRules, + SandboxConfig, +} from '@deepcode/core/config'; +import { dispatchToolCall } from '@deepcode/core/harness'; import { HookDispatcher } from '@deepcode/core/hooks'; import { loadMemory } from '@deepcode/core/memory'; +import { + closeAllMcpServers, + connectAllMcpServers, + expandMcpResourceRefs, + type McpClientHandle, +} from '@deepcode/core/mcp'; import { applyStyle, findStyle, loadOutputStyles } from '@deepcode/core/output-styles'; +import { + collectPluginContributions, + wirePlugins, + type PluginCapabilityBridge, + type WireResult, +} from '@deepcode/core/plugins'; import { buildSkillsDescriptionBlock, loadSkills, makeSkillTool } from '@deepcode/core/skills'; -import { BUILTIN_TOOLS, ToolRegistry } from '@deepcode/core/tools'; +import { + BashTool, + BUILTIN_TOOLS, + installToolSearch, + ReadTool, + ToolRegistry, + WebFetchTool, + WriteTool, + type ToolHandler, + type ToolResult, +} 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 RuntimeCompositionDiagnostic { + source: 'mcp' | 'plugin'; + code: string; + severity: 'warning' | 'error'; + message: string; +} + +export interface RuntimePreparedMessage { + text: string; + diagnostics: RuntimeCompositionDiagnostic[]; +} + +export interface RuntimeCompositionServices { + collectPluginContributions: typeof collectPluginContributions; + connectAllMcpServers: typeof connectAllMcpServers; + closeAllMcpServers: typeof closeAllMcpServers; + expandMcpResourceRefs: typeof expandMcpResourceRefs; + wirePlugins: typeof wirePlugins; +} + +const DEFAULT_SERVICES: RuntimeCompositionServices = { + collectPluginContributions, + connectAllMcpServers, + closeAllMcpServers, + expandMcpResourceRefs, + wirePlugins, +}; + export interface RuntimeCompositionOptions { cwd: string; directory?: string; settings: DeepCodeSettings; - pluginDirs?: string[]; + mode?: Mode; + provider?: Provider; + requestApproval?: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; + signal?: AbortSignal; + services?: Partial; } export interface RuntimeComposition { @@ -25,6 +85,9 @@ export interface RuntimeComposition { model: string; effort: Effort; pluginDirs: string[]; + diagnostics: RuntimeCompositionDiagnostic[]; + prepareUserMessage: (text: string) => Promise; + close: () => Promise; } export function resolveComposedMode( @@ -35,12 +98,37 @@ export function resolveComposedMode( return modeExplicit ? requested : (settings.permissions?.defaultMode ?? requested); } -/** Compose filesystem-backed instructions and tools inside the trusted host. */ +/** Compose trusted filesystem context and turn-scoped external resources. */ export async function composeRuntime( options: RuntimeCompositionOptions, ): Promise { const { cwd, directory, settings } = options; - const pluginDirs = options.pluginDirs ?? []; + const services = { ...DEFAULT_SERVICES, ...options.services }; + const diagnostics: RuntimeCompositionDiagnostic[] = []; + const pluginsEnabled = settings.plugins?.globalEnabled !== false; + let pluginDiscoverySucceeded = true; + let pluginDirs: string[] = []; + let pluginMcpServers: Record = {}; + + if (pluginsEnabled) { + try { + const contribution = await services.collectPluginContributions({ + directory, + disabled: settings.disabledPlugins, + }); + pluginDirs = contribution.dirs; + pluginMcpServers = contribution.mcpServers; + } catch (error) { + pluginDiscoverySucceeded = false; + diagnostics.push({ + source: 'plugin', + code: 'plugin_discovery_failed', + severity: 'error', + message: `Plugin discovery failed: ${(error as Error).message}`, + }); + } + } + const [memory, skills, styles] = await Promise.all([ loadMemory({ cwd, @@ -58,6 +146,107 @@ export async function composeRuntime( const tools = new ToolRegistry(BUILTIN_TOOLS); if (skills.length > 0) tools.register(makeSkillTool(skills)); + const hooks = new HookDispatcher({ + hooks: settings.hooks, + disableAllHooks: settings.disableAllHooks, + allowedHttpHookUrls: settings.allowedHttpHookUrls, + }); + + const allMcpServers = { ...pluginMcpServers, ...(settings.mcpServers ?? {}) }; + let mcpServers: McpClientHandle[] = []; + if (Object.keys(allMcpServers).length > 0) { + try { + const connected = await services.connectAllMcpServers(allMcpServers, { + enabledOnly: settings.enabledMcpjsonServers, + disabled: settings.disabledMcpjsonServers ?? [], + directory, + }); + mcpServers = connected.handles; + for (const error of connected.errors) { + diagnostics.push({ + source: 'mcp', + code: 'mcp_connect_failed', + severity: 'warning', + message: `MCP server "${error.serverName}" failed: ${error.error}`, + }); + } + const deferred = []; + for (const handle of mcpServers) { + const defer = allMcpServers[handle.serverName]?.alwaysLoad === false; + for (const tool of handle.tools) { + if (defer) { + deferred.push({ + name: tool.name, + description: tool.definition.description, + expand: () => tool, + }); + } else { + tools.register(tool); + } + } + } + installToolSearch(tools, deferred); + } catch (error) { + diagnostics.push({ + source: 'mcp', + code: 'mcp_composition_failed', + severity: 'error', + message: `MCP composition failed: ${(error as Error).message}`, + }); + } + } + + let pluginsWire: WireResult | null = null; + if (pluginsEnabled && pluginDiscoverySucceeded) { + try { + pluginsWire = await services.wirePlugins({ + directory, + disabled: settings.disabledPlugins, + hooks, + capabilities: buildPluginCapabilityBridge({ + cwd, + mode: options.mode ?? 'default', + permissions: settings.permissions, + hooks, + provider: options.provider, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + requestApproval: options.requestApproval, + signal: options.signal, + }), + sandbox: settings.sandbox, + log: () => undefined, + }); + for (const plugin of pluginsWire.plugins) { + for (const tool of plugin.contributedTools) { + if (!tools.get(tool.name)) tools.register(tool); + } + } + for (const mismatch of pluginsWire.hashMismatches) { + diagnostics.push({ + source: 'plugin', + code: 'plugin_hash_mismatch', + severity: 'warning', + message: mismatch, + }); + } + for (const name of pluginsWire.spawnFailures) { + diagnostics.push({ + source: 'plugin', + code: 'plugin_start_failed', + severity: 'warning', + message: `Plugin "${name}" failed to start`, + }); + } + } catch (error) { + diagnostics.push({ + source: 'plugin', + code: 'plugin_wire_failed', + severity: 'error', + message: `Plugin wire-up failed: ${(error as Error).message}`, + }); + } + } let systemPrompt = DEFAULT_APP_SERVER_SYSTEM_PROMPT; if (memory.text) systemPrompt += `\n\n${memory.text}`; @@ -65,16 +254,112 @@ export async function composeRuntime( if (skillsBlock) systemPrompt += `\n\n${skillsBlock}`; systemPrompt = applyStyle(systemPrompt, findStyle(styles, settings.outputStyle ?? 'default')); + let closed = false; return { tools, - hooks: new HookDispatcher({ - hooks: settings.hooks, - disableAllHooks: settings.disableAllHooks, - allowedHttpHookUrls: settings.allowedHttpHookUrls, - }), + hooks, systemPrompt, model: settings.model ?? 'deepseek-chat', effort: settings.effortLevel ?? 'medium', pluginDirs, + diagnostics, + prepareUserMessage: async (text) => { + if (mcpServers.length === 0) return { text, diagnostics: [] }; + const expanded = await services.expandMcpResourceRefs(text, mcpServers); + return { + text: expanded.text, + diagnostics: expanded.errors.map((error) => ({ + source: 'mcp' as const, + code: 'mcp_resource_failed', + severity: 'warning' as const, + message: `MCP resource @${error.ref.server}:${error.ref.uri} failed: ${error.error}`, + })), + }; + }, + close: async () => { + if (closed) return; + closed = true; + await Promise.allSettled([ + pluginsWire?.shutdown() ?? Promise.resolve(), + services.closeAllMcpServers(mcpServers), + ]); + }, + }; +} + +interface PluginBridgeOptions { + cwd: string; + mode: Mode; + permissions?: PermissionRules; + hooks: HookDispatcher; + provider?: Provider; + autoMode?: DeepCodeSettings['autoMode']; + sandboxConfig?: SandboxConfig; + requestApproval?: RuntimeCompositionOptions['requestApproval']; + signal?: AbortSignal; +} + +/** Route plugin subprocess capabilities through the same policy and hook gates as agent tools. */ +export function buildPluginCapabilityBridge(options: PluginBridgeOptions): PluginCapabilityBridge { + const execute = async ( + handler: ToolHandler, + input: Record, + ): Promise => { + const verdict = await dispatchToolCall({ + tool: handler.name, + input, + mode: options.mode, + rules: options.permissions, + hooks: options.hooks, + cwd: options.cwd, + autoMode: options.autoMode, + autoModeProvider: options.provider, + }); + let allowed = verdict.decision === 'allow'; + if (verdict.decision === 'ask' && options.requestApproval) { + const decision = await options.requestApproval( + handler.name, + `Plugin requested ${handler.name}: ${verdict.reason}`, + ); + allowed = decision === 'allow' || decision === 'always'; + } + if (!allowed) throw new Error(`Plugin capability blocked: ${verdict.reason}`); + + const result = await handler.execute(input, { + cwd: options.cwd, + signal: options.signal, + sandboxConfig: options.sandboxConfig, + }); + await options.hooks.dispatch({ + event: 'PostToolUse', + cwd: options.cwd, + triggeredAt: new Date().toISOString(), + payload: { + tool: handler.name, + input, + result_content: result.content.slice(0, 1000), + is_error: result.isError ?? false, + source: 'plugin', + }, + }); + if (result.isError) throw new Error(result.content); + return result; + }; + + return { + fs_read: async (path) => (await execute(ReadTool, { file_path: path })).content, + fs_write: async (path, content) => { + await execute(WriteTool, { file_path: path, content }); + }, + bash: async (command) => { + const result = await execute(BashTool, { command }); + const data = (result.data ?? {}) as { stderr?: string; exitCode?: number }; + return { + stdout: result.content, + stderr: data.stderr ?? '', + exitCode: data.exitCode ?? 0, + }; + }, + fetch: async (url) => (await execute(WebFetchTool, { url })).content, }; } diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts index 404a798..957e806 100644 --- a/apps/server/src/runtime-executor.test.ts +++ b/apps/server/src/runtime-executor.test.ts @@ -155,17 +155,32 @@ describe('RuntimeHostExecutor', () => { it('uses per-turn composition defaults and always releases the host lease', async () => { const provider = new StreamingProvider(); const close = vi.fn(); + const prepareUserMessage = vi.fn(async () => ({ + text: 'composed user message', + diagnostics: [ + { + source: 'mcp', + code: 'mcp_resource_failed', + severity: 'warning' as const, + message: 'bad ref', + }, + ], + })); const executor = new RuntimeHostExecutor({ createHost: () => ({ host: new RuntimeHost({ provider, tools: new ToolRegistry(), cwd: '/workspace' }), systemPrompt: 'composed instructions', model: 'deepseek-reasoner', effort: 'low', + diagnostics: [ + { source: 'mcp', code: 'mcp_connect_failed', severity: 'warning', message: 'offline' }, + ], + prepareUserMessage, close, }), }); - await executor.execute({ + const result = await executor.execute({ thread: { ...thread, turns: [] }, turn: { id: 'turn-lease', @@ -187,6 +202,14 @@ describe('RuntimeHostExecutor', () => { maxTokens: 1_500, }), ); + expect(provider.seenMessages.at(-1)).toEqual( + expect.objectContaining({ + role: 'user', + content: [{ type: 'text', text: 'composed user message' }], + }), + ); + expect(result.items.filter((item) => item.type === 'error')).toHaveLength(2); + expect(prepareUserMessage).toHaveBeenCalledWith('use composition'); expect(close).toHaveBeenCalledOnce(); }); @@ -216,7 +239,46 @@ describe('RuntimeHostExecutor', () => { ...protocolCallbacks(), }); - expect(createHost).toHaveBeenCalledWith('/workspace', 'default', { modeExplicit: false }); + expect(createHost).toHaveBeenCalledWith( + '/workspace', + 'default', + expect.objectContaining({ modeExplicit: false }), + ); + }); + + it('releases the host lease when message preparation fails', async () => { + const close = vi.fn(); + const executor = new RuntimeHostExecutor({ + createHost: () => ({ + host: new RuntimeHost({ + provider: new StreamingProvider(), + tools: new ToolRegistry(), + cwd: '/workspace', + }), + prepareUserMessage: async () => { + throw new Error('resource expansion failed'); + }, + close, + }), + }); + + await expect( + executor.execute({ + thread: { ...thread, turns: [] }, + turn: { + id: 'turn-prepare-failure', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { text: 'expand this' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }), + ).rejects.toThrow('resource expansion failed'); + expect(close).toHaveBeenCalledOnce(); }); it('ignores non-message protocol items when rebuilding provider history', () => { diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts index 26174fe..7570fd1 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -15,18 +15,39 @@ export interface RuntimeHostExecutorOptions { createHost: ( cwd: string, mode: Mode, - context: { modeExplicit: boolean }, + context: RuntimeHostCreationContext, ) => Promise | RuntimeHost | RuntimeHostLease; systemPrompt?: string; model?: string; sessionManager?: SessionManager; } +export interface RuntimeHostCreationContext { + modeExplicit: boolean; + signal: AbortSignal; + requestApproval: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; +} + export interface RuntimeHostLease { host: RuntimeHost; systemPrompt?: string; model?: string; effort?: Effort; + diagnostics?: Array<{ + source: string; + code: string; + severity: 'warning' | 'error'; + message: string; + }>; + prepareUserMessage?: (text: string) => Promise<{ + text: string; + diagnostics: Array<{ + source: string; + code: string; + severity: 'warning' | 'error'; + message: string; + }>; + }>; close?: () => Promise | void; } @@ -40,18 +61,37 @@ export class RuntimeHostExecutor implements TurnExecutor { const requestedMode = args.input.mode; const modeExplicit = isMode(requestedMode); const mode: Mode = modeExplicit ? requestedMode : 'default'; + const interactionItems: TurnExecutionItem[] = []; + const requestApproval = async (toolName: string, reason: string) => { + const decision = await args.requestApproval(toolName, reason); + interactionItems.push({ + type: 'approval', + payload: { toolName, decision, reason }, + }); + return decision; + }; const created = await this.options.createHost(args.thread.cwd, mode, { modeExplicit, + signal: args.signal, + requestApproval, }); 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); + let text = typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input); + for (const diagnostic of lease.diagnostics ?? []) { + interactionItems.push({ type: 'error', payload: diagnostic }); + } + if (lease.prepareUserMessage) { + const prepared = await lease.prepareUserMessage(text); + text = prepared.text; + for (const diagnostic of prepared.diagnostics) { + interactionItems.push({ type: 'error', payload: diagnostic }); + } + } 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({ @@ -72,14 +112,10 @@ export class RuntimeHostExecutor implements TurnExecutor { persistSessionMessages: false, systemReminders: false, approval: async (toolName, _input, verdict) => { - const decision = await args.requestApproval( + const decision = await 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) => { diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 004deaf..08be852 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -319,9 +319,10 @@ model tool call - CLI doctor、Desktop About、VS Code command 与 LSP command 均消费同一个 diagnostics DTO, 客户端不再自行解释配置来源或 trust gate。 - 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,并统一资源引用/错误诊断。 + output style、hooks 与 model/effort/mode defaults。 +- MCP 与 plugin subprocess 已接入同一 turn-scoped lease:eager/deferred tools、resource refs、 + best-effort diagnostics、plugin capability policy gates 与 deterministic cleanup 共享 host 边界; + plugin trust hash 覆盖全部安装文件,skills-only plugin 不再被强制启动进程。 - hook 安全继续收敛到定义哈希级审核:未审阅或已变化的非托管 command hook 默认跳过, 并在 diagnostics 中暴露来源与审核状态;目录 trust 只是第一道门。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index d83bc46..480db90 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -84,8 +84,12 @@ 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. +composed prompt, and releases it in `finally`. Trusted plugin contributions and MCP servers now live +inside that boundary: tools share the registry, deferred MCP tools sit behind `ToolSearch`, resource +references expand before provider input, and connection/plugin failures are persisted as value-free +diagnostics without aborting healthy peers. Plugin capability RPC goes through mode, permission, +hook, approval, and sandbox gates. Explicit client model/effort/mode values still override trusted +settings. ## Entrypoints @@ -110,5 +114,4 @@ 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/docs/design/plugin-security.md b/docs/design/plugin-security.md index 3e087f0..984ed4e 100644 --- a/docs/design/plugin-security.md +++ b/docs/design/plugin-security.md @@ -192,9 +192,9 @@ host process 启动 ▼ 对每个启用的 plugin: │ ├─ 重新计算 sourceHash → 与 trust.json 比对 │ ├─ hash 不一致 → 跳过,告警 - │ └─ hash 一致 → 启动 sandbox 子进程 + │ └─ hash 一致 → 装载声明式贡献;仅带 index.js 的 executable plugin 启动 sandbox 子进程 │ - ▼ Sandbox 子进程启动: + ▼ executable plugin 的 Sandbox 子进程启动: │ ├─ bwrap (Linux) / sandbox-exec (macOS) 包装 node 进程 │ ├─ 文件系统: │ │ ✓ 可读: plugin 自己的目录 / /tmp/deepcode-plugin-/ diff --git a/docs/milestones/M5.md b/docs/milestones/M5.md index ed6a144..6502fb8 100644 --- a/docs/milestones/M5.md +++ b/docs/milestones/M5.md @@ -70,7 +70,7 @@ Auto-trigger via description matching is implicit — by including `buildSkillsD ## Tests added -- `plugins/manifest.test.ts` — 12 tests covering: manifest validation, hash determinism, hash sensitivity (manifest + SKILL.md changes), trust round-trip, install, discovery, drift detection, disabled list, untrusted skip +- `plugins/manifest.test.ts` — covers manifest validation, deterministic whole-plugin-tree hash sensitivity (including executable code and SKILL.md), trust round-trip, install, direct data-directory discovery, drift detection, disabled list, and untrusted skip - `skills/tool.test.ts` — 6 tests covering: tool shape, known skill lookup, args appending, plugin-qualified names, missing skill, missing arg ## Verified diff --git a/packages/core/package.json b/packages/core/package.json index 3d9ce7e..d15e6b3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -60,14 +60,26 @@ "types": "./dist/hooks/index.d.ts", "import": "./dist/hooks/index.js" }, + "./harness": { + "types": "./dist/harness/index.d.ts", + "import": "./dist/harness/index.js" + }, "./memory": { "types": "./dist/memory/index.d.ts", "import": "./dist/memory/index.js" }, + "./mcp": { + "types": "./dist/mcp/index.d.ts", + "import": "./dist/mcp/index.js" + }, "./output-styles": { "types": "./dist/output-styles/index.d.ts", "import": "./dist/output-styles/index.js" }, + "./plugins": { + "types": "./dist/plugins/index.d.ts", + "import": "./dist/plugins/index.js" + }, "./skills": { "types": "./dist/skills/index.d.ts", "import": "./dist/skills/index.js" diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 21ed2d3..7985e62 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -84,6 +84,8 @@ export interface ConnectMcpOpts { elicit?: McpElicitHandler; /** Override $HOME for OAuth token storage (tests). */ home?: string; + /** Direct DeepCode data directory for OAuth token storage. */ + directory?: string; /** Diagnostics sink for the OAuth flow (browser-open prompt, etc.). */ log?: (msg: string) => void; } @@ -227,10 +229,17 @@ export async function connectMcpServer( oauthProvider = await createMcpOAuthProvider(serverName, { scopes: config.oauthScopes, home: opts.home, + directory: opts.directory, log: opts.log ?? ((m) => process.stderr.write(`[mcp:${serverName}] ${m}\n`)), }); } - const transport = await buildTransport(serverName, config, kind, oauthProvider); + let transport: Transport; + try { + transport = await buildTransport(serverName, config, kind, oauthProvider); + } catch (error) { + oauthProvider?.closeReceiver(); + throw error; + } // Advertise elicitation support only when the host gave us a handler — an // empty `elicitation: {}` capability means form mode (SDK default). const capabilities = opts.elicit ? { elicitation: {} } : {}; @@ -253,113 +262,127 @@ export async function connectMcpServer( // First connect with no/expired token throws UnauthorizedError after opening // the browser. Wait for the loopback redirect, finish the exchange, retry. if (oauthProvider && err instanceof UnauthorizedError) { - const code = await oauthProvider.waitForCode(); - await (transport as FinishableTransport).finishAuth(code); - await client.connect(transport); + try { + const code = await oauthProvider.waitForCode(); + await (transport as FinishableTransport).finishAuth(code); + await client.connect(transport); + } catch (authError) { + await client.close().catch(() => undefined); + throw authError; + } } else { oauthProvider?.closeReceiver(); + await client.close().catch(() => undefined); throw err; } } finally { oauthProvider?.closeReceiver(); } - // List the tools the server exposes - const listed = await client.listTools(); - const tools: ToolHandler[] = listed.tools.map((t) => { - const qualified = `mcp__${serverName}__${t.name}`; - const def: ToolDefinition = { - name: qualified, - description: t.description ?? `(MCP tool from ${serverName})`, - inputSchema: (t.inputSchema ?? { type: 'object', properties: {} }) as Record, - }; - return { - name: qualified, - definition: def, - async execute(input: Record): Promise { - try { - const result = (await client.callTool({ - name: t.name, - arguments: input, - })) as { content?: Array<{ type?: string; text?: string }>; isError?: boolean }; - // MCP returns { content: [{type:'text', text:'...'}, ...] } - const textParts = - (result.content ?? []) - .filter((c) => c.type === 'text') - .map((c) => c.text ?? '') - .join('\n') || ''; - return { - content: textParts ? capMcpOutput(textParts) : '(MCP tool returned no text content)', - isError: result.isError === true, - data: { serverName, serverToolName: t.name }, - }; - } catch (err) { - return { - content: `MCP call failed: ${(err as Error).message}`, - isError: true, - }; - } - }, - }; - }); + try { + // List the tools the server exposes + const listed = await client.listTools(); + const tools: ToolHandler[] = listed.tools.map((t) => { + const qualified = `mcp__${serverName}__${t.name}`; + const def: ToolDefinition = { + name: qualified, + description: t.description ?? `(MCP tool from ${serverName})`, + inputSchema: (t.inputSchema ?? { type: 'object', properties: {} }) as Record< + string, + unknown + >, + }; + return { + name: qualified, + definition: def, + async execute(input: Record): Promise { + try { + const result = (await client.callTool({ + name: t.name, + arguments: input, + })) as { content?: Array<{ type?: string; text?: string }>; isError?: boolean }; + // MCP returns { content: [{type:'text', text:'...'}, ...] } + const textParts = + (result.content ?? []) + .filter((c) => c.type === 'text') + .map((c) => c.text ?? '') + .join('\n') || ''; + return { + content: textParts ? capMcpOutput(textParts) : '(MCP tool returned no text content)', + isError: result.isError === true, + data: { serverName, serverToolName: t.name }, + }; + } catch (err) { + return { + content: `MCP call failed: ${(err as Error).message}`, + isError: true, + }; + } + }, + }; + }); - // Resources (best-effort, capability-gated). A server without the `resources` - // capability — or one that errors on resources/list — just yields []. - let resources: McpResourceMeta[] = []; - let resourceTemplates: McpResourceTemplateMeta[] = []; - if (client.getServerCapabilities()?.resources) { - try { - const r = await client.listResources(); - resources = (r.resources ?? []).map((res) => ({ - uri: res.uri, - name: res.name, - description: res.description, - mimeType: res.mimeType, - })); - } catch { - /* server advertised resources but list failed — degrade to none */ + // Resources (best-effort, capability-gated). A server without the `resources` + // capability — or one that errors on resources/list — just yields []. + let resources: McpResourceMeta[] = []; + let resourceTemplates: McpResourceTemplateMeta[] = []; + if (client.getServerCapabilities()?.resources) { + try { + const r = await client.listResources(); + resources = (r.resources ?? []).map((res) => ({ + uri: res.uri, + name: res.name, + description: res.description, + mimeType: res.mimeType, + })); + } catch { + /* server advertised resources but list failed — degrade to none */ + } + try { + const rt = await client.listResourceTemplates(); + resourceTemplates = (rt.resourceTemplates ?? []).map((t) => ({ + uriTemplate: t.uriTemplate, + name: t.name, + description: t.description, + mimeType: t.mimeType, + })); + } catch { + /* templates are optional even within the resources capability */ + } } - try { - const rt = await client.listResourceTemplates(); - resourceTemplates = (rt.resourceTemplates ?? []).map((t) => ({ - uriTemplate: t.uriTemplate, - name: t.name, - description: t.description, - mimeType: t.mimeType, - })); - } catch { - /* templates are optional even within the resources capability */ - } - } - // Prompts (best-effort, capability-gated — same degradation as resources). - let prompts: McpPromptMeta[] = []; - if (client.getServerCapabilities()?.prompts) { - try { - const p = await client.listPrompts(); - prompts = (p.prompts ?? []).map((pr) => ({ - name: pr.name, - description: pr.description, - arguments: pr.arguments, - })); - } catch { - /* server advertised prompts but list failed — degrade to none */ + // Prompts (best-effort, capability-gated — same degradation as resources). + let prompts: McpPromptMeta[] = []; + if (client.getServerCapabilities()?.prompts) { + try { + const p = await client.listPrompts(); + prompts = (p.prompts ?? []).map((pr) => ({ + name: pr.name, + description: pr.description, + arguments: pr.arguments, + })); + } catch { + /* server advertised prompts but list failed — degrade to none */ + } } - } - return { - serverName, - client, - transport, - transportKind: kind, - tools, - resources, - resourceTemplates, - prompts, - async close() { - await client.close(); - }, - }; + return { + serverName, + client, + transport, + transportKind: kind, + tools, + resources, + resourceTemplates, + prompts, + async close() { + await client.close(); + }, + }; + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } } /** @@ -551,7 +574,14 @@ export interface ConnectAllResult { export async function connectAllMcpServers( servers: Record, - opts: { enabledOnly?: string[]; disabled?: string[]; elicit?: McpElicitHandler } = {}, + opts: { + enabledOnly?: string[]; + disabled?: string[]; + elicit?: McpElicitHandler; + home?: string; + directory?: string; + log?: (msg: string) => void; + } = {}, ): Promise { const handles: McpClientHandle[] = []; const errors: Array<{ serverName: string; error: string }> = []; @@ -562,7 +592,12 @@ export async function connectAllMcpServers( if (enabled && !enabled.has(name)) continue; if (disabled.has(name)) continue; try { - const handle = await connectMcpServer(name, cfg, { elicit: opts.elicit }); + const handle = await connectMcpServer(name, cfg, { + elicit: opts.elicit, + home: opts.home, + directory: opts.directory, + log: opts.log, + }); handles.push(handle); } catch (err) { errors.push({ serverName: name, error: (err as Error).message }); diff --git a/packages/core/src/mcp/oauth.test.ts b/packages/core/src/mcp/oauth.test.ts index df7581b..e38bba6 100644 --- a/packages/core/src/mcp/oauth.test.ts +++ b/packages/core/src/mcp/oauth.test.ts @@ -25,6 +25,14 @@ describe('McpAuthStore', () => { expect(mcpAuthPath('git/hub', home)).toBe(join(home, '.deepcode', 'mcp-auth', 'git_hub.json')); }); + it('can persist under a direct DeepCode data directory', async () => { + const directory = join(home, 'custom-data'); + const store = new McpAuthStore('srv', home, directory); + await store.patch({ tokens: TOKENS }); + expect(store.path()).toBe(join(directory, 'mcp-auth', 'srv.json')); + expect((await store.read()).tokens).toEqual(TOKENS); + }); + it('read() returns {} when absent; patch persists + merges', async () => { const s = new McpAuthStore('srv', home); expect(await s.read()).toEqual({}); diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index 8b2af48..fb4d861 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -29,8 +29,12 @@ function sanitizeName(name: string): string { return name.replace(/[^A-Za-z0-9_.-]/g, '_'); } -export function mcpAuthPath(serverName: string, home: string = homedir()): string { - return join(home, '.deepcode', 'mcp-auth', `${sanitizeName(serverName)}.json`); +export function mcpAuthPath( + serverName: string, + home: string = homedir(), + directory?: string, +): string { + return join(directory ?? join(home, '.deepcode'), 'mcp-auth', `${sanitizeName(serverName)}.json`); } /** File-backed persistence for one server's OAuth state. */ @@ -38,10 +42,11 @@ export class McpAuthStore { constructor( private readonly serverName: string, private readonly home: string = homedir(), + private readonly directory?: string, ) {} path(): string { - return mcpAuthPath(this.serverName, this.home); + return mcpAuthPath(this.serverName, this.home, this.directory); } async read(): Promise { @@ -171,6 +176,8 @@ export interface OAuthProviderOpts { openBrowser?: (url: string) => void; /** Where redirect/instruction lines go. */ log?: (msg: string) => void; + /** Direct DeepCode data directory for persisted OAuth state. */ + directory?: string; } /** @@ -253,7 +260,7 @@ export async function createMcpOAuthProvider( serverName: string, opts: OAuthProviderOpts & { home?: string } = {}, ): Promise { - const store = new McpAuthStore(serverName, opts.home); + const store = new McpAuthStore(serverName, opts.home, opts.directory); const receiver = await startLoopbackReceiver(); return new DeepCodeOAuthProvider(store, receiver, opts); } diff --git a/packages/core/src/plugins/manifest.test.ts b/packages/core/src/plugins/manifest.test.ts index d5535b6..6ae193c 100644 --- a/packages/core/src/plugins/manifest.test.ts +++ b/packages/core/src/plugins/manifest.test.ts @@ -74,6 +74,14 @@ describe('plugin manifest', () => { expect(h1).not.toBe(h2); }); + it('computeSourceHash includes executable plugin code', async () => { + await fakePlugin(src, { name: 'runtime', version: '1' }); + await fs.writeFile(join(src, 'index.js'), 'module.exports = 1;'); + const before = await computeSourceHash(src); + await fs.writeFile(join(src, 'index.js'), 'module.exports = 2;'); + expect(await computeSourceHash(src)).not.toBe(before); + }); + it('trust state round-trip', async () => { await saveTrustState(home, { plugins: { @@ -120,6 +128,34 @@ describe('plugin manifest', () => { expect(r.hashMismatches).toEqual([]); }); + it('discovers trusted plugins from a direct DeepCode data directory', async () => { + const directory = join(home, 'custom-data'); + const pluginDir = join(directory, 'plugins', 'direct'); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + join(pluginDir, 'plugin.json'), + JSON.stringify({ name: 'direct', version: '1.0.0' }), + ); + const sourceHash = await computeSourceHash(pluginDir); + await saveTrustState( + home, + { + plugins: { + direct: { + version: '1.0.0', + installedAt: '2026-08-01T00:00:00.000Z', + sourceHash, + trustedBy: 'user', + }, + }, + }, + directory, + ); + + const result = await discoverPlugins({ home, directory }); + expect(result.plugins.map((plugin) => plugin.manifest.name)).toEqual(['direct']); + }); + it('discoverPlugins flags hash mismatch', async () => { await fakePlugin(src, { name: 'drift', version: '0.1.0' }); await installLocal({ sourcePath: src, home }); diff --git a/packages/core/src/plugins/manifest.ts b/packages/core/src/plugins/manifest.ts index 4cbc004..a789aa6 100644 --- a/packages/core/src/plugins/manifest.ts +++ b/packages/core/src/plugins/manifest.ts @@ -57,41 +57,45 @@ export interface TrustState { plugins: Record; } -export function pluginsDir(home: string): string { - return join(home, '.deepcode', 'plugins'); +export function pluginsDir(home: string, directory?: string): string { + return join(directory ?? join(home, '.deepcode'), 'plugins'); } -export function trustFilePath(home: string): string { - return join(home, '.deepcode', 'plugins-trust.json'); +export function trustFilePath(home: string, directory?: string): string { + return join(directory ?? join(home, '.deepcode'), 'plugins-trust.json'); } -/** - * Compute source hash of a plugin directory — currently hashes manifest.json - * + all SKILL.md files. M5.1 will extend to all .js files when sandbox runs. - */ +/** Compute a deterministic trust hash over every installed plugin file. */ export async function computeSourceHash(pluginPath: string): Promise { const hash = createHash('sha256'); - const manifestPath = join(pluginPath, 'plugin.json'); - hash.update(await fs.readFile(manifestPath)); - // Hash skills (frontmatter-driven prompts are user-facing) - try { - const skillsDir = join(pluginPath, 'skills'); - const entries = await fs.readdir(skillsDir); - for (const e of entries.sort()) { - const skillFile = join(skillsDir, e, 'SKILL.md'); - try { - const content = await fs.readFile(skillFile); - hash.update(content); - } catch { - // skip missing - } - } - } catch { - // no skills/ dir + const files = await pluginFiles(pluginPath); + for (const relativePath of files) { + hash.update(relativePath); + hash.update('\0'); + hash.update(await fs.readFile(join(pluginPath, relativePath))); + hash.update('\0'); } return hash.digest('hex').slice(0, 16); } +async function pluginFiles(root: string, current = root, prefix = ''): Promise { + const files: string[] = []; + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name === '.git') continue; + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + const absolutePath = join(current, entry.name); + if (entry.isDirectory()) { + files.push(...(await pluginFiles(root, absolutePath, relativePath))); + } else if (entry.isFile()) { + files.push(relativePath); + } else if (entry.isSymbolicLink()) { + throw new Error(`Plugin contains unsupported symbolic link: ${relativePath}`); + } + } + return files; +} + export async function readManifest(pluginPath: string): Promise { const raw = await fs.readFile(join(pluginPath, 'plugin.json'), 'utf8'); const parsed = JSON.parse(raw) as PluginManifest; @@ -103,9 +107,9 @@ export async function readManifest(pluginPath: string): Promise return parsed; } -export async function loadTrustState(home: string): Promise { +export async function loadTrustState(home: string, directory?: string): Promise { try { - const raw = await fs.readFile(trustFilePath(home), 'utf8'); + const raw = await fs.readFile(trustFilePath(home, directory), 'utf8'); return JSON.parse(raw) as TrustState; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { plugins: {} }; @@ -113,9 +117,13 @@ export async function loadTrustState(home: string): Promise { } } -export async function saveTrustState(home: string, state: TrustState): Promise { - const path = trustFilePath(home); - await fs.mkdir(join(home, '.deepcode'), { recursive: true }); +export async function saveTrustState( + home: string, + state: TrustState, + directory?: string, +): Promise { + const path = trustFilePath(home, directory); + await fs.mkdir(directory ?? join(home, '.deepcode'), { recursive: true }); await fs.writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8'); } @@ -150,6 +158,8 @@ export async function installLocal(opts: InstallOptions): Promise { const home = opts.home ?? homedir(); - const root = pluginsDir(home); + const root = pluginsDir(home, opts.directory); let entries: string[]; try { entries = await fs.readdir(root); @@ -172,7 +182,7 @@ export async function discoverPlugins(opts: DiscoverOptions = {}): Promise<{ return { plugins: [], hashMismatches: [] }; throw err; } - const trust = await loadTrustState(home); + const trust = await loadTrustState(home, opts.directory); const out: InstalledPlugin[] = []; const hashMismatches: string[] = []; const disabled = new Set(opts.disabled ?? []); @@ -186,7 +196,13 @@ export async function discoverPlugins(opts: DiscoverOptions = {}): Promise<{ } catch { continue; } - const liveHash = await computeSourceHash(pluginPath); + let liveHash: string; + try { + liveHash = await computeSourceHash(pluginPath); + } catch (error) { + hashMismatches.push(`${manifest.name}: source hash failed (${(error as Error).message})`); + continue; + } const trusted = trust.plugins[manifest.name]; if (!trusted) { // Plugin in dir but never trusted — skip + flag @@ -216,9 +232,13 @@ export async function discoverPlugins(opts: DiscoverOptions = {}): Promise<{ * Hooks are merged separately by wirePlugins (it needs the live dispatcher). */ export async function collectPluginContributions( - opts: { home?: string; disabled?: string[] } = {}, + opts: { home?: string; directory?: string; disabled?: string[] } = {}, ): Promise<{ dirs: string[]; mcpServers: Record }> { - const { plugins } = await discoverPlugins({ home: opts.home, disabled: opts.disabled }); + const { plugins } = await discoverPlugins({ + home: opts.home, + directory: opts.directory, + disabled: opts.disabled, + }); const enabled = plugins.filter((p) => p.enabled); const dirs = enabled.map((p) => p.path); const mcpServers: Record = {}; diff --git a/packages/core/src/plugins/runtime/subprocess.ts b/packages/core/src/plugins/runtime/subprocess.ts index a732276..fe207ce 100644 --- a/packages/core/src/plugins/runtime/subprocess.ts +++ b/packages/core/src/plugins/runtime/subprocess.ts @@ -129,10 +129,33 @@ export class PluginSubprocess { } async stop(): Promise { - if (this.child && this.alive) { - this.child.kill('SIGTERM'); - this.alive = false; - } + const child = this.child; + if (!child || !this.alive) return; + await new Promise((resolveStop) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(forceTimer); + clearTimeout(safetyTimer); + child.off('exit', finish); + child.off('error', finish); + resolveStop(); + }; + const forceTimer = setTimeout(() => { + if (!settled) child.kill('SIGKILL'); + }, 1_000); + const safetyTimer = setTimeout(finish, 2_000); + child.once('exit', finish); + child.once('error', finish); + if (!child.kill('SIGTERM')) { + finish(); + return; + } + if (settled) return; + }); + this.alive = false; + this.child = null; } /** diff --git a/packages/core/src/plugins/wireup.test.ts b/packages/core/src/plugins/wireup.test.ts index 1ffa0e7..d4f5fb0 100644 --- a/packages/core/src/plugins/wireup.test.ts +++ b/packages/core/src/plugins/wireup.test.ts @@ -113,11 +113,9 @@ rl.on('line', () => {}); { contributes: { hooks: {} } }, `process.stdin.on('data', () => {});`, ); - // Mutate the index.js AFTER trust was recorded → hash drift - // (computeSourceHash hashes plugin.json + skills/*/SKILL.md, NOT - // index.js. So we need to mutate the manifest itself.) - const manifestPath = join(pluginsDir(home), 'drifty', 'plugin.json'); - await fs.writeFile(manifestPath, JSON.stringify({ name: 'drifty', version: '0.0.2' }), 'utf8'); + // Executable code is part of the trust hash, so post-install mutation disables the plugin. + const entryPath = join(pluginsDir(home), 'drifty', 'index.js'); + await fs.writeFile(entryPath, `process.stdout.write('tampered');`, 'utf8'); const hooks = new HookDispatcher({}); const r = await wirePlugins({ home, hooks, capabilities: makeBridge(), log: () => {} }); @@ -130,6 +128,38 @@ rl.on('line', () => {}); } }, 10000); + it('keeps declarative skills-only plugins active without spawning a subprocess', async () => { + const dir = join(pluginsDir(home), 'skills-only'); + await fs.mkdir(join(dir, 'skills', 'demo'), { recursive: true }); + await fs.writeFile( + join(dir, 'plugin.json'), + JSON.stringify({ name: 'skills-only', version: '0.0.1' }), + ); + await fs.writeFile(join(dir, 'skills', 'demo', 'SKILL.md'), '# Demo'); + const hash = await computeSourceHash(dir); + await saveTrustState(home, { + plugins: { + 'skills-only': { + version: '0.0.1', + installedAt: '2026-08-01T00:00:00.000Z', + sourceHash: hash, + trustedBy: 'user', + }, + }, + }); + + const result = await wirePlugins({ + home, + hooks: new HookDispatcher({}), + capabilities: makeBridge(), + log: () => undefined, + }); + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0]?.subprocess).toBeUndefined(); + expect(result.spawnFailures).toEqual([]); + await result.shutdown(); + }); + it('honors `disabled` option (plugin discovered but enabled=false → not spawned)', async () => { await makeInstalledPlugin( home, diff --git a/packages/core/src/plugins/wireup.ts b/packages/core/src/plugins/wireup.ts index 007fe6f..fd57ac5 100644 --- a/packages/core/src/plugins/wireup.ts +++ b/packages/core/src/plugins/wireup.ts @@ -33,6 +33,8 @@ export interface PluginCapabilityBridge { export interface WirePluginsOpts { home?: string; + /** Direct DeepCode data directory (contains plugins/ and plugins-trust.json). */ + directory?: string; /** Plugins disabled via settings.disabledPlugins. */ disabled?: string[]; /** Live hook dispatcher to merge plugin-contributed hooks into. */ @@ -53,7 +55,8 @@ export interface WirePluginsOpts { export interface WiredPlugin { plugin: InstalledPlugin; - subprocess: PluginSubprocess; + /** Present only when the plugin has an executable index.js runtime. */ + subprocess?: PluginSubprocess; /** Hook events the plugin's manifest declared it contributes to. */ contributedHookEvents: string[]; /** Tool handlers (M5.2 keeps this empty — Skills cover this; M5.3 first-class tools). */ @@ -80,70 +83,94 @@ export async function wirePlugins(opts: WirePluginsOpts): Promise { const home = opts.home ?? homedir(); const log = opts.log ?? ((s: string) => process.stderr.write(s + '\n')); - const discoverOpts: DiscoverOptions = { home, disabled: opts.disabled }; + const discoverOpts: DiscoverOptions = { + home, + directory: opts.directory, + disabled: opts.disabled, + }; const { plugins: discovered, hashMismatches } = await discoverPlugins(discoverOpts); if (discovered.length === 0) { return { plugins: [], hashMismatches, spawnFailures: [], shutdown: async () => {} }; } - // Spawn each enabled plugin + // A skills/MCP/hooks-only plugin is valid and does not need a subprocess. + const enabled = discovered.filter((p) => p.enabled); + const runnable: InstalledPlugin[] = []; + for (const plugin of enabled) { + try { + await fs.access(`${plugin.path}/index.js`); + runnable.push(plugin); + } catch { + // No executable runtime; declarative contributions remain active. + } + } + + // Spawn each enabled plugin that declares executable code via index.js. const subprocesses = await spawnAllPlugins({ - plugins: discovered.filter((p) => p.enabled), + plugins: runnable, host: opts.capabilities, sandbox: opts.sandbox, }); - // spawnAllPlugins returns successfully-started subprocesses, each exposing - // its source plugin via the `.plugin` getter. Failed starts are dropped. - const enabled = discovered.filter((p) => p.enabled); - const successfulNames = new Set(); - const wired: WiredPlugin[] = []; - for (const sub of subprocesses) { - const plugin = sub.plugin; - successfulNames.add(plugin.manifest.name); - const events = Object.keys(plugin.manifest.contributes?.hooks ?? {}); - wired.push({ - plugin, - subprocess: sub, - contributedHookEvents: events, - contributedTools: sub.toolHandlers(), - }); - // Merge declared hook matchers into the live dispatcher. The hooks - // manifest from a plugin must follow the same shape as settings.hooks. - const declared = plugin.manifest.contributes?.hooks; - if (declared && Object.keys(declared).length > 0) { - opts.hooks.mergeHooks(declared as Hooks); + try { + // spawnAllPlugins returns successfully-started subprocesses, each exposing + // its source plugin via the `.plugin` getter. Failed starts are dropped. + const successfulNames = new Set(); + const wired: WiredPlugin[] = []; + for (const sub of subprocesses) { + successfulNames.add(sub.plugin.manifest.name); + } + for (const plugin of enabled) { + const sub = subprocesses.find( + (candidate) => candidate.plugin.manifest.name === plugin.manifest.name, + ); + const events = Object.keys(plugin.manifest.contributes?.hooks ?? {}); + wired.push({ + plugin, + subprocess: sub, + contributedHookEvents: events, + contributedTools: sub?.toolHandlers() ?? [], + }); + // Merge declared hook matchers into the live dispatcher. The hooks + // manifest from a plugin must follow the same shape as settings.hooks. + const declared = plugin.manifest.contributes?.hooks; + if (declared && Object.keys(declared).length > 0) { + opts.hooks.mergeHooks(declared as Hooks); + } } - } - const spawnFailures: string[] = []; - for (const p of enabled) { - if (!successfulNames.has(p.manifest.name)) spawnFailures.push(p.manifest.name); - } - if (spawnFailures.length > 0) { - log(` ⊞ Plugins: ${spawnFailures.length} failed to start (${spawnFailures.join(', ')})`); - } - if (wired.length > 0) { - const hookEventCount = wired.reduce((n, w) => n + w.contributedHookEvents.length, 0); - log(` ⊞ Plugins: ${wired.length} loaded · ${hookEventCount} hook event(s) contributed`); - } + const spawnFailures: string[] = []; + for (const p of runnable) { + if (!successfulNames.has(p.manifest.name)) spawnFailures.push(p.manifest.name); + } + if (spawnFailures.length > 0) { + log(` ⊞ Plugins: ${spawnFailures.length} failed to start (${spawnFailures.join(', ')})`); + } + if (wired.length > 0) { + const hookEventCount = wired.reduce((n, w) => n + w.contributedHookEvents.length, 0); + log(` ⊞ Plugins: ${wired.length} loaded · ${hookEventCount} hook event(s) contributed`); + } - let shut = false; - const shutdown = async (): Promise => { - if (shut) return; - shut = true; - await shutdownAllPlugins(subprocesses); - }; + let shut = false; + const shutdown = async (): Promise => { + if (shut) return; + shut = true; + await shutdownAllPlugins(subprocesses); + }; - return { plugins: wired, hashMismatches, spawnFailures, shutdown }; + return { plugins: wired, hashMismatches, spawnFailures, shutdown }; + } catch (error) { + await shutdownAllPlugins(subprocesses); + throw error; + } } /** * Sanity helper exposed for tests / tools: returns whether a plugin dir is * present without spawning anything. */ -export async function hasInstalledPlugins(home?: string): Promise { - const root = (home ?? homedir()) + '/.deepcode/plugins'; +export async function hasInstalledPlugins(home?: string, directory?: string): Promise { + const root = directory ? `${directory}/plugins` : `${home ?? homedir()}/.deepcode/plugins`; try { const entries = await fs.readdir(root); return entries.some((e) => !e.startsWith('.')); From a23e8c307963ee51bb1753038ae2b20f1b31b329 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 16:32:32 +0800 Subject: [PATCH 25/33] feat: require exact project hook review --- apps/cli/src/cli.ts | 8 + apps/cli/src/headless.ts | 17 +- apps/cli/src/hooks-cmd.test.ts | 57 ++++++ apps/cli/src/hooks-cmd.ts | 71 +++++++ apps/cli/src/parse-args.ts | 1 + apps/cli/src/repl.ts | 17 +- apps/cli/src/trust-cmd.test.ts | 2 +- apps/cli/src/trust-cmd.ts | 6 +- apps/server/README.md | 4 + apps/server/src/default-runtime.ts | 12 +- docs/CODEX_ALIGNMENT_PLAN.md | 5 +- docs/design/app-server-v1.md | 5 + packages/core/src/config/diagnostics.test.ts | 17 ++ packages/core/src/config/diagnostics.ts | 19 +- packages/core/src/config/hook-trust.test.ts | 65 +++++++ packages/core/src/config/hook-trust.ts | 185 +++++++++++++++++++ packages/core/src/config/index.ts | 2 + packages/core/src/index.ts | 3 + 18 files changed, 485 insertions(+), 11 deletions(-) create mode 100644 apps/cli/src/hooks-cmd.test.ts create mode 100644 apps/cli/src/hooks-cmd.ts create mode 100644 packages/core/src/config/hook-trust.test.ts create mode 100644 packages/core/src/config/hook-trust.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index be60d6b..f0d240d 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -18,6 +18,7 @@ import { TrustStore } from './trust.js'; import { runPluginsCommand, runSkillsCommand } from './list-cmd.js'; import { runSetupToken } from './setup-token.js'; import { runCompletion } from './completion.js'; +import { runHooksCommand } from './hooks-cmd.js'; async function main(): Promise { const args = parseArgs(process.argv.slice(2)); @@ -96,6 +97,13 @@ async function main(): Promise { output: process.stdout, }); } + if (args.positional[0] === 'hooks') { + return runHooksCommand(args.positional.slice(1), { + cwd: process.cwd(), + output: process.stdout, + errOutput: process.stderr, + }); + } if (args.positional[0] === 'setup-token') { return runSetupToken({ token: args.positional[1] }); } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index f76f997..3437d2d 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -20,6 +20,7 @@ import { DeepSeekProvider, EFFORT_PARAMS, HookDispatcher, + HookTrustStore, ReadTool, RuntimeHost, SessionManager, @@ -94,13 +95,27 @@ export async function runHeadless(opts: HeadlessOpts): Promise { const loaded = await loadSettings({ cwd, home: opts.home, settingsPath: opts.settingsPath }); const trustStore = new TrustStore({ home: opts.home }); const trustStatus = await trustStore.statusFor(cwd); - const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); + const gate = gateUntrustedSettings(loaded, trustStatus); + let settings = gate.settings; + const gated = gate.gated; if (gated.length > 0) { errOutput.write( `Untrusted directory — ignoring project ${gated.join(', ')} (can execute code). ` + `Run \`deepcode trust\` to enable.\n`, ); } + const hookReview = await new HookTrustStore({ home: opts.home }).review( + cwd, + loaded, + settings.hooks, + ); + settings = { ...settings, hooks: hookReview.hooks }; + const pendingHooks = hookReview.reviews.filter((review) => !review.trusted); + if (pendingHooks.length > 0) { + errOutput.write( + `${pendingHooks.length} project command hook(s) disabled; review with \`deepcode hooks list\`.\n`, + ); + } const credsStore = new CredentialsStore({ home: opts.home }); const creds = await resolveCredentials({ store: credsStore, diff --git a/apps/cli/src/hooks-cmd.test.ts b/apps/cli/src/hooks-cmd.test.ts new file mode 100644 index 0000000..2ffa441 --- /dev/null +++ b/apps/cli/src/hooks-cmd.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { DirectoryTrustStore, writeSettings } from '@deepcode/core'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { runHooksCommand } from './hooks-cmd.js'; + +function sink(): { stream: Writable; text: () => string } { + let value = ''; + return { + stream: new Writable({ + write(chunk, _encoding, callback) { + value += chunk.toString(); + callback(); + }, + }), + text: () => value, + }; +} + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +describe('runHooksCommand', () => { + it('lists, trusts, and revokes exact project command hooks', async () => { + const home = await mkdtemp(join(tmpdir(), 'dc-hooks-command-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-hooks-command-cwd-')); + roots.push(home, cwd); + await new DirectoryTrustStore({ home }).trust(cwd, 'full'); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo reviewed' }] }] }, + }); + + const pending = sink(); + expect(await runHooksCommand(['list'], { cwd, home, output: pending.stream })).toBe(0); + expect(pending.text()).toMatch(/pending.*Stop.*echo reviewed/); + + const trusted = sink(); + await runHooksCommand(['trust', '--all'], { cwd, home, output: trusted.stream }); + expect(trusted.text()).toContain('Trusted 1'); + const listed = sink(); + await runHooksCommand(['list'], { cwd, home, output: listed.stream }); + expect(listed.text()).toMatch(/trusted.*Stop.*echo reviewed/); + + await runHooksCommand(['revoke'], { cwd, home, output: sink().stream }); + const revoked = sink(); + await runHooksCommand(['list'], { cwd, home, output: revoked.stream }); + expect(revoked.text()).toMatch(/pending/); + }); +}); diff --git a/apps/cli/src/hooks-cmd.ts b/apps/cli/src/hooks-cmd.ts new file mode 100644 index 0000000..222d0b8 --- /dev/null +++ b/apps/cli/src/hooks-cmd.ts @@ -0,0 +1,71 @@ +import type { Writable } from 'node:stream'; + +import { + DirectoryTrustStore, + gateUntrustedSettings, + HookTrustStore, + loadSettings, +} from '@deepcode/core'; + +export async function runHooksCommand( + args: string[], + deps: { cwd: string; home?: string; output?: Writable; errOutput?: Writable }, +): Promise { + const out = deps.output ?? process.stdout; + const err = deps.errOutput ?? process.stderr; + const directoryTrust = new DirectoryTrustStore({ home: deps.home }); + const trustStatus = await directoryTrust.statusFor(deps.cwd); + if (trustStatus !== 'trusted') { + err.write('Trust this directory with `deepcode trust` before reviewing project hooks.\n'); + return 2; + } + const loaded = await loadSettings({ cwd: deps.cwd, home: deps.home }); + const gate = gateUntrustedSettings(loaded, trustStatus); + const store = new HookTrustStore({ home: deps.home }); + const result = await store.review(deps.cwd, loaded, gate.settings.hooks); + const action = args[0] ?? 'list'; + + if (action === 'trust') { + const pending = result.reviews.filter((review) => !review.trusted); + const requested = args.slice(1); + if (requested.length === 0) { + for (const review of pending) { + out.write(`pending ${review.hash} ${review.event} ${review.command}\n`); + } + err.write('Pass one or more hook hashes, or `--all`, after reviewing the definitions.\n'); + return 2; + } + const selected = requested.includes('--all') + ? pending + : pending.filter((review) => requested.includes(review.hash)); + const unknown = requested.filter( + (value) => value !== '--all' && !pending.some((review) => review.hash === value), + ); + if (unknown.length > 0) { + err.write(`Unknown or already-trusted hook hash: ${unknown.join(', ')}\n`); + return 2; + } + await store.trust(deps.cwd, selected); + out.write(`Trusted ${selected.length} project command hook definition(s) in ${deps.cwd}.\n`); + return 0; + } + if (action === 'revoke') { + await store.revoke(deps.cwd); + out.write(`Revoked project command hook trust in ${deps.cwd}.\n`); + return 0; + } + if (action !== 'list') { + err.write('Usage: deepcode hooks [list | trust | revoke]\n'); + return 2; + } + if (result.reviews.length === 0) { + out.write('No project command hooks require review.\n'); + return 0; + } + for (const review of result.reviews) { + out.write( + `${review.trusted ? 'trusted' : 'pending'} ${review.hash} ${review.event} ${review.command}\n`, + ); + } + return 0; +} diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 6149fe6..825fe94 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -287,6 +287,7 @@ USAGE 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 hooks list|trust|revoke Review exact project command-hook definitions deepcode plugins list [--json] List installed plugins deepcode plugins install Install a plugin (gh:owner/repo | name@npm | ./path) deepcode plugins uninstall Remove an installed plugin diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index fb88bee..35abfa1 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -7,6 +7,7 @@ import { DeepSeekProvider, EFFORT_PARAMS, HookDispatcher, + HookTrustStore, ReadTool, RuntimeHost, SessionManager, @@ -212,13 +213,27 @@ export async function startRepl(opts: ReplOpts): Promise { const loaded = await loadSettings({ cwd, home: opts.home, settingsPath: opts.settingsPath }); const trustStore = new TrustStore({ home: opts.home }); const trustStatus = await trustStore.statusFor(cwd); - const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); + const gate = gateUntrustedSettings(loaded, trustStatus); + let settings = gate.settings; + const gated = gate.gated; if (gated.length > 0) { output.write( ` ⚠ Untrusted directory — ignoring project ${gated.join(', ')} (can execute code).\n` + ` Run \`deepcode trust\` here to enable them.\n`, ); } + const hookReview = await new HookTrustStore({ home: opts.home }).review( + cwd, + loaded, + settings.hooks, + ); + settings = { ...settings, hooks: hookReview.hooks }; + const pendingHooks = hookReview.reviews.filter((review) => !review.trusted); + if (pendingHooks.length > 0) { + output.write( + ` ⚠ ${pendingHooks.length} project command hook(s) disabled; review with \`deepcode hooks list\`.\n`, + ); + } const credsStore = new CredentialsStore({ home: opts.home }); const creds = await resolveCredentials({ store: credsStore, diff --git a/apps/cli/src/trust-cmd.test.ts b/apps/cli/src/trust-cmd.test.ts index efebdca..452c84d 100644 --- a/apps/cli/src/trust-cmd.test.ts +++ b/apps/cli/src/trust-cmd.test.ts @@ -31,7 +31,7 @@ describe('runTrustCommand', () => { const out = sink(); const code = await runTrustCommand([], { cwd, home, output: out.stream }); expect(code).toBe(0); - expect(out.text()).toMatch(/Trusted .* enabled here/); + expect(out.text()).toMatch(/Trusted .* review command hooks/); expect(await new TrustStore({ home }).statusFor(cwd)).toBe('trusted'); }); diff --git a/apps/cli/src/trust-cmd.ts b/apps/cli/src/trust-cmd.ts index 9702c19..7c0b10c 100644 --- a/apps/cli/src/trust-cmd.ts +++ b/apps/cli/src/trust-cmd.ts @@ -1,8 +1,8 @@ // `deepcode trust [--plan-only | --remove | --list]` — manage directory trust. // Spec: docs/DEVELOPMENT_PLAN.md §3.15.10 // -// Trusting a directory lets its project-local settings.json run code (hooks, -// MCP servers, apiKeyHelper, statusLine). Until trusted, those are gated (see +// Trusting a directory lets its project-local settings.json contribute authority-bearing +// settings. Command hooks still require definition-level review. Until trusted, those are gated (see // core/config/trust-gate). The user-global layer is always trusted. import type { Writable } from 'node:stream'; @@ -43,7 +43,7 @@ export async function runTrustCommand(args: string[], deps: TrustCmdDeps): Promi out.write( mode === 'plan-only' ? `Trusted ${deps.cwd} (plan-only — project config can run, but the session starts in plan mode).\n` - : `Trusted ${deps.cwd} — project hooks, MCP servers, apiKeyHelper, and statusLine are now enabled here.\n`, + : `Trusted ${deps.cwd} — project config is enabled; review command hooks with \`deepcode hooks list\`.\n`, ); return 0; } diff --git a/apps/server/README.md b/apps/server/README.md index d89ec74..8f4fff1 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -29,3 +29,7 @@ calling `RuntimeHost`; clients remain unaware of those files. The lease has an e hook. Trusted plugin contributions and MCP servers are composed in that lease: eager/deferred tools share the host registry, MCP resource references are expanded before the model call, startup/resource failures become value-free turn diagnostics, and every subprocess/connection closes in `finally`. + +Trusted-directory project/local command hooks still require exact-definition review. The shared +hook trust store disables pending or changed definitions and exposes value-free warnings through +`config/diagnostics`; use `deepcode hooks list` and `deepcode hooks trust ` to review them. diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 93ea788..6b0a019 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -1,5 +1,10 @@ import { CredentialsStore, resolveCredentials } from '@deepcode/core/credentials'; -import { DirectoryTrustStore, gateUntrustedSettings, loadSettings } from '@deepcode/core/config'; +import { + DirectoryTrustStore, + gateUntrustedSettings, + HookTrustStore, + 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'; @@ -12,6 +17,7 @@ export function createDefaultTurnExecutor( options: { forceFileCredentials?: boolean } = {}, ): RuntimeHostExecutor { const trustStore = new DirectoryTrustStore({ directory: home }); + const hookTrustStore = new HookTrustStore({ directory: home }); const sessionManager = new SessionManager({ root: home ? `${home}/sessions` : undefined, }); @@ -19,7 +25,9 @@ export function createDefaultTurnExecutor( createHost: async (cwd, mode, context) => { const loaded = await loadSettings({ cwd, directory: home }); const trustStatus = await trustStore.statusFor(cwd); - const { settings } = gateUntrustedSettings(loaded, trustStatus); + const gate = gateUntrustedSettings(loaded, trustStatus); + const hookReview = await hookTrustStore.review(cwd, loaded, gate.settings.hooks); + const settings = { ...gate.settings, hooks: hookReview.hooks }; const effectiveMode = resolveComposedMode(mode, context.modeExplicit, settings); const credentials = await resolveCredentials({ store: new CredentialsStore({ diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 08be852..08329a3 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -323,8 +323,9 @@ model tool call - MCP 与 plugin subprocess 已接入同一 turn-scoped lease:eager/deferred tools、resource refs、 best-effort diagnostics、plugin capability policy gates 与 deterministic cleanup 共享 host 边界; plugin trust hash 覆盖全部安装文件,skills-only plugin 不再被强制启动进程。 -- hook 安全继续收敛到定义哈希级审核:未审阅或已变化的非托管 command hook 默认跳过, - 并在 diagnostics 中暴露来源与审核状态;目录 trust 只是第一道门。 +- project/local command hook 已收敛到规范化定义哈希审核:未审阅或已变化的定义默认跳过, + `deepcode hooks list|trust |revoke` 提供显式管理,diagnostics 暴露来源与审核状态; + 目录 trust 只是第一道门,user/explicit override 仍是可信层。 - 在 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 480db90..b2bf94d 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -91,6 +91,11 @@ diagnostics without aborting healthy peers. Plugin capability RPC goes through m hook, approval, and sandbox gates. Explicit client model/effort/mode values still override trusted settings. +Directory trust does not directly authorize project/local command hooks. Their canonical event, +matcher, and handler definition is SHA-256 pinned in the shared hook trust store; pending or changed +definitions are removed before `HookDispatcher` construction and reported value-free through +configuration diagnostics. User-global and explicit override hooks remain trusted layers. + ## Entrypoints After `pnpm build`, either command starts the same handler: diff --git a/packages/core/src/config/diagnostics.test.ts b/packages/core/src/config/diagnostics.test.ts index 10b53a7..ce56849 100644 --- a/packages/core/src/config/diagnostics.test.ts +++ b/packages/core/src/config/diagnostics.test.ts @@ -58,4 +58,21 @@ describe('diagnoseSettings', () => { expect(report.issues).toEqual([]); expect(report.layers.find((layer) => layer.layer === 'project')?.trusted).toBe(true); }); + + it('reports project command hooks that still require definition review', 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'), { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo project' }] }] }, + }); + + const report = await diagnoseSettings({ cwd, home, trustStatus: 'trusted' }); + expect(report.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'hook_review_required', pointer: '/hooks/Stop' }), + ]), + ); + expect(JSON.stringify(report)).not.toContain('echo project'); + }); }); diff --git a/packages/core/src/config/diagnostics.ts b/packages/core/src/config/diagnostics.ts index 3dd3c29..a91a6f9 100644 --- a/packages/core/src/config/diagnostics.ts +++ b/packages/core/src/config/diagnostics.ts @@ -3,12 +3,13 @@ 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'; +import { HookTrustStore } from './hook-trust.js'; export type SettingsDiagnosticSeverity = 'info' | 'warning' | 'error'; export interface SettingsDiagnosticIssue { severity: SettingsDiagnosticSeverity; - code: 'schema_validation' | 'untrusted_setting_gated'; + code: 'schema_validation' | 'untrusted_setting_gated' | 'hook_review_required'; message: string; pointer?: string; source?: { layer: SettingsLayerName; path: string }; @@ -56,6 +57,22 @@ export async function diagnoseSettings( }); } + if (options.trustStatus === 'trusted') { + const hookReview = await new HookTrustStore({ + home: options.home, + directory: options.directory, + }).review(options.cwd, loaded, gate.settings.hooks); + for (const review of hookReview.reviews.filter((item) => !item.trusted)) { + issues.push({ + severity: 'warning', + code: 'hook_review_required', + message: `Project command hook ${review.event} (${review.hash}) is disabled until reviewed`, + pointer: `/hooks/${review.event}`, + source: review.source, + }); + } + } + const layerPaths: Record = { user: loaded.sources.userPath, project: loaded.sources.projectPath, diff --git a/packages/core/src/config/hook-trust.test.ts b/packages/core/src/config/hook-trust.test.ts new file mode 100644 index 0000000..fad50ae --- /dev/null +++ b/packages/core/src/config/hook-trust.test.ts @@ -0,0 +1,65 @@ +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 { HookTrustStore } from './hook-trust.js'; +import { loadSettings, 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('HookTrustStore', () => { + it('filters project command hooks until their exact definition is trusted', async () => { + const home = await mkdtemp(join(tmpdir(), 'dc-hook-trust-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-hook-trust-cwd-')); + roots.push(home, cwd); + await writeSettings(join(home, '.deepcode', 'settings.json'), { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo user' }] }] }, + }); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo project' }, + { type: 'prompt', prompt: 'safe context' }, + ], + }, + ], + }, + }); + const loaded = await loadSettings({ cwd, home }); + const store = new HookTrustStore({ home }); + + const pending = await store.review(cwd, loaded, loaded.merged.hooks); + expect(pending.hooks?.Stop?.[0]?.hooks).toHaveLength(1); + expect(pending.hooks?.PreToolUse?.[0]?.hooks.map((hook) => hook.type)).toEqual(['prompt']); + expect(pending.reviews).toEqual([ + expect.objectContaining({ trusted: false, command: 'echo project' }), + ]); + + await store.trust(cwd, pending.reviews); + const trusted = await store.review(cwd, loaded, loaded.merged.hooks); + expect(trusted.hooks?.PreToolUse?.[0]?.hooks.map((hook) => hook.type)).toEqual([ + 'command', + 'prompt', + ]); + + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + hooks: { + PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo changed' }] }], + }, + }); + const changed = await loadSettings({ cwd, home }); + const reviewed = await store.review(cwd, changed, changed.merged.hooks); + expect(reviewed.reviews[0]).toEqual(expect.objectContaining({ trusted: false })); + expect(reviewed.hooks?.PreToolUse).toBeUndefined(); + }); +}); diff --git a/packages/core/src/config/hook-trust.ts b/packages/core/src/config/hook-trust.ts new file mode 100644 index 0000000..799a92a --- /dev/null +++ b/packages/core/src/config/hook-trust.ts @@ -0,0 +1,185 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +import type { LoadedSettings } from './loader.js'; +import type { HookEventName, HookHandler, Hooks } from './types.js'; + +export interface HookReview { + hash: string; + event: HookEventName; + matcher?: string; + command: string; + source: { layer: 'project' | 'local'; path: string }; + trusted: boolean; +} + +interface HookTrustState { + version: 1; + directories: Record>; +} + +export class HookTrustStore { + private readonly directory: string; + + constructor(options: { home?: string; directory?: string } = {}) { + this.directory = options.directory ?? join(options.home ?? homedir(), '.deepcode'); + } + + filePath(): string { + return join(this.directory, 'hook-trust.json'); + } + + async load(): Promise { + try { + return validateState(JSON.parse(await fs.readFile(this.filePath(), 'utf8')) as unknown); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { version: 1, directories: {} }; + } + throw new Error(`Failed to load hook trust: ${(error as Error).message}`); + } + } + + async save(state: HookTrustState): 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 review( + cwd: string, + loaded: LoadedSettings, + hooks: Hooks | undefined, + ): Promise<{ + hooks: Hooks | undefined; + reviews: HookReview[]; + }> { + if (!hooks) return { hooks, reviews: [] }; + const state = await this.load(); + const trusted = state.directories[resolve(cwd)] ?? {}; + const filtered: Hooks = {}; + const reviews: HookReview[] = []; + + for (const [event, matchers] of Object.entries(hooks) as Array< + [HookEventName, NonNullable] + >) { + const source = sourceForEvent(loaded, event); + const nextMatchers = matchers + .map((matcher) => { + const nextHandlers = matcher.hooks.filter((handler) => { + if (handler.type !== 'command' || !source) return true; + const hash = hookDefinitionHash(event, matcher.matcher, handler); + const review: HookReview = { + hash, + event, + matcher: matcher.matcher, + command: handler.command ?? '', + source, + trusted: trusted[hash]?.sourcePath === source.path, + }; + reviews.push(review); + return review.trusted; + }); + return nextHandlers.length > 0 ? { ...matcher, hooks: nextHandlers } : undefined; + }) + .filter((matcher): matcher is NonNullable => matcher !== undefined); + if (nextMatchers.length > 0) filtered[event] = nextMatchers; + } + return { hooks: filtered, reviews }; + } + + async trust(cwd: string, reviews: HookReview[]): Promise { + const state = await this.load(); + const key = resolve(cwd); + const entries = state.directories[key] ?? {}; + for (const review of reviews) { + entries[review.hash] = { + trustedAt: new Date().toISOString(), + sourcePath: review.source.path, + }; + } + state.directories[key] = entries; + await this.save(state); + } + + async revoke(cwd: string): Promise { + const state = await this.load(); + delete state.directories[resolve(cwd)]; + await this.save(state); + } +} + +export function hookDefinitionHash( + event: HookEventName, + matcher: string | undefined, + handler: HookHandler, +): string { + const canonical = canonicalJson({ event, matcher: matcher ?? '', handler }); + return createHash('sha256').update(canonical).digest('hex').slice(0, 20); +} + +function sourceForEvent( + loaded: LoadedSettings, + event: HookEventName, +): HookReview['source'] | undefined { + const source = loaded.provenance[`/hooks/${escapePointer(event)}`]; + return source?.layer === 'project' || source?.layer === 'local' + ? { layer: source.layer, path: source.path } + : undefined; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function escapePointer(value: string): string { + return value.replaceAll('~', '~0').replaceAll('/', '~1'); +} + +function validateState(value: unknown): HookTrustState { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('hook-trust.json must contain an object'); + } + const raw = value as { version?: unknown; directories?: unknown }; + if ( + raw.version !== 1 || + !raw.directories || + typeof raw.directories !== 'object' || + Array.isArray(raw.directories) + ) { + throw new Error('hook-trust.json must contain version 1 and directories'); + } + const state: HookTrustState = { version: 1, directories: {} }; + for (const [cwd, entries] of Object.entries(raw.directories as Record)) { + if (!safeKey(cwd) || !entries || typeof entries !== 'object' || Array.isArray(entries)) { + throw new Error(`Invalid hook trust directory ${cwd}`); + } + const next: HookTrustState['directories'][string] = {}; + for (const [hash, entry] of Object.entries(entries as Record)) { + if (!safeKey(hash) || !entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`Invalid hook trust entry ${hash}`); + } + const item = entry as { trustedAt?: unknown; sourcePath?: unknown }; + if (typeof item.trustedAt !== 'string' || typeof item.sourcePath !== 'string') { + throw new Error(`Invalid hook trust entry ${hash}`); + } + next[hash] = { trustedAt: item.trustedAt, sourcePath: item.sourcePath }; + } + state.directories[cwd] = next; + } + return state; +} + +function safeKey(value: string): boolean { + return value !== '__proto__' && value !== 'prototype' && value !== 'constructor'; +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index f534b96..de76c46 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -44,6 +44,8 @@ export { type DirectoryTrustStoreOptions, } from './trust-store.js'; +export { HookTrustStore, hookDefinitionHash, type HookReview } from './hook-trust.js'; + export { diagnoseSettings, type DiagnoseSettingsOptions, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3abc386..c9f78b7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,6 +86,8 @@ export { deepMerge, appendAllowMatcher, gateUntrustedSettings, + HookTrustStore, + hookDefinitionHash, TRUST_GATED_FIELDS, evaluatePermission, matchRule, @@ -98,6 +100,7 @@ export { type TrustStatus, type TrustGatedField, type GateResult, + type HookReview, type PermissionVerdict, type PermissionRequest, type Hooks, From a7bcd406230202459de4f1958730fc2119442b3d Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 16:50:05 +0800 Subject: [PATCH 26/33] feat: add redacted structured tracing --- apps/cli/src/cli.ts | 10 + apps/cli/src/diagnostics-cmd.test.ts | 52 ++++ apps/cli/src/diagnostics-cmd.ts | 35 +++ apps/cli/src/parse-args.ts | 1 + apps/desktop/src/lib/protocol-agent.test.ts | 1 + apps/lsp/src/handler.test.ts | 1 + apps/server/README.md | 9 + apps/server/package.json | 4 + apps/server/scripts/build-sidecar.mjs | 10 +- apps/server/src/diagnostic-export.ts | 107 ++++++++ apps/server/src/index.ts | 2 + apps/server/src/run.test.ts | 10 +- apps/server/src/run.ts | 36 ++- apps/server/src/server.test.ts | 25 ++ apps/server/src/server.ts | 137 +++++++++- apps/server/src/structured-logger.test.ts | 127 +++++++++ apps/server/src/structured-logger.ts | 271 ++++++++++++++++++++ apps/vscode/scripts/build.mjs | 11 +- apps/vscode/src/protocol-runtime.test.ts | 1 + docs/CODEX_ALIGNMENT_PLAN.md | 6 +- packages/core/src/config/schema.ts | 22 +- packages/protocol/README.md | 10 +- packages/protocol/src/codec.test.ts | 22 +- packages/protocol/src/codec.ts | 1 + packages/protocol/src/runtime.test.ts | 8 + packages/protocol/src/runtime.ts | 38 ++- packages/protocol/src/types.ts | 34 ++- 27 files changed, 940 insertions(+), 51 deletions(-) create mode 100644 apps/cli/src/diagnostics-cmd.test.ts create mode 100644 apps/cli/src/diagnostics-cmd.ts create mode 100644 apps/server/src/diagnostic-export.ts create mode 100644 apps/server/src/structured-logger.test.ts create mode 100644 apps/server/src/structured-logger.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index f0d240d..4d89c7c 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -7,6 +7,7 @@ import { CredentialsStore, VERSION, diagnoseSettings, redact } from '@deepcode/c import { runAppServer } from '@deepcode/app-server'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; +import { runDiagnosticsCommand } from './diagnostics-cmd.js'; import { runHeadless } from './headless.js'; import { runMcpCommand } from './mcp-cmd.js'; import { runOnboarding } from './onboarding.js'; @@ -91,6 +92,15 @@ async function main(): Promise { }); return 0; } + if (args.positional[0] === 'diagnostics') { + const home = process.env.DEEPCODE_HOME ?? resolve(homedir(), '.deepcode'); + return runDiagnosticsCommand(args.positional.slice(1), { + home, + cwd: process.cwd(), + output: process.stdout, + errOutput: process.stderr, + }); + } if (args.positional[0] === 'trust') { return runTrustCommand(args.positional.slice(1), { cwd: process.cwd(), diff --git a/apps/cli/src/diagnostics-cmd.test.ts b/apps/cli/src/diagnostics-cmd.test.ts new file mode 100644 index 0000000..0eb1f62 --- /dev/null +++ b/apps/cli/src/diagnostics-cmd.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { runDiagnosticsCommand } from './diagnostics-cmd.js'; + +let root: string | undefined; + +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +function capture(stream: PassThrough): () => string { + let value = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + value += chunk; + }); + return () => value; +} + +describe('runDiagnosticsCommand', () => { + it('exports through the shared app-server sanitizer', async () => { + root = await mkdtemp(join(tmpdir(), 'deepcode-diagnostics-cli-')); + const cwd = join(root, 'private-workspace-name'); + const output = new PassThrough(); + const errOutput = new PassThrough(); + const outputText = capture(output); + + await expect( + runDiagnosticsCommand(['export'], { cwd, home: root, output, errOutput }), + ).resolves.toBe(0); + const path = outputText().trim().replace('Wrote redacted diagnostic bundle: ', ''); + const bundle = await readFile(path, 'utf8'); + expect(bundle).not.toContain('private-workspace-name'); + }); + + it('rejects unknown diagnostics actions', async () => { + root = await mkdtemp(join(tmpdir(), 'deepcode-diagnostics-cli-')); + const output = new PassThrough(); + const errOutput = new PassThrough(); + const errorText = capture(errOutput); + await expect( + runDiagnosticsCommand([], { cwd: root, home: root, output, errOutput }), + ).resolves.toBe(2); + expect(errorText()).toContain('diagnostics export'); + }); +}); diff --git a/apps/cli/src/diagnostics-cmd.ts b/apps/cli/src/diagnostics-cmd.ts new file mode 100644 index 0000000..2bd2ab9 --- /dev/null +++ b/apps/cli/src/diagnostics-cmd.ts @@ -0,0 +1,35 @@ +import { join, resolve } from 'node:path'; +import type { Writable } from 'node:stream'; + +import { exportDiagnosticBundle } from '@deepcode/app-server/diagnostics'; +import { diagnoseSettings } from '@deepcode/core'; + +import { TrustStore } from './trust.js'; + +export interface DiagnosticsCommandOptions { + cwd: string; + home: string; + output: Writable; + errOutput: Writable; +} + +export async function runDiagnosticsCommand( + args: string[], + options: DiagnosticsCommandOptions, +): Promise { + if (args[0] !== 'export') { + options.errOutput.write('Usage: deepcode diagnostics export\n'); + return 2; + } + const cwd = resolve(options.cwd); + const trustStatus = await new TrustStore({ directory: options.home }).statusFor(cwd); + const config = await diagnoseSettings({ cwd, directory: options.home, trustStatus }); + const result = await exportDiagnosticBundle({ + home: options.home, + cwd, + config, + logPath: join(options.home, 'logs', 'app-server.ndjson'), + }); + options.output.write(`Wrote redacted diagnostic bundle: ${result.path}\n`); + return 0; +} diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 825fe94..6b4ab73 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -286,6 +286,7 @@ USAGE 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 diagnostics export Write a redacted app-server support bundle deepcode trust [--plan-only] Trust this directory's project config (hooks/MCP/...) deepcode hooks list|trust|revoke Review exact project command-hook definitions deepcode plugins list [--json] List installed plugins diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index 8be61c9..232e6f6 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -25,6 +25,7 @@ class FakeTransport implements ProtocolTransport { structuredToolEvents: true, interactiveRequests: true, configDiagnostics: true, + diagnosticExport: true, }, }; } diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index fd97a96..9b2f7f8 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -21,6 +21,7 @@ const capabilities: InitializeResult = { structuredToolEvents: true, interactiveRequests: true, configDiagnostics: true, + diagnosticExport: true, }, }; diff --git a/apps/server/README.md b/apps/server/README.md index 8f4fff1..0191d31 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -33,3 +33,12 @@ failures become value-free turn diagnostics, and every subprocess/connection clo Trusted-directory project/local command hooks still require exact-definition review. The shared hook trust store disables pending or changed definitions and exposes value-free warnings through `config/diagnostics`; use `deepcode hooks list` and `deepcode hooks trust ` to review them. + +The host generates one `traceId` per turn and attaches it to durable and transient protocol events. +Bounded NDJSON logs live under `logs/app-server.ndjson`; their schema only permits correlation ids, +event names, status codes, and durations. It never serializes protocol payloads, prompts, commands, +tool arguments/results, or error messages. `diagnostics/export` (also available as +`deepcode diagnostics export`) writes a mode-0600 support bundle under `diagnostics/`. The export +hashes workspace/config paths, omits issue messages and configuration values, re-sanitizes every log +record, and can be removed without affecting threads. Removing `logs/` and `diagnostics/` is the +rollback for this optional observability layer. diff --git a/apps/server/package.json b/apps/server/package.json index 10e424b..328615a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,6 +18,10 @@ "./client": { "types": "./dist/client.d.ts", "import": "./dist/client.js" + }, + "./diagnostics": { + "types": "./dist/diagnostic-export.d.ts", + "import": "./dist/diagnostic-export.js" } }, "scripts": { diff --git a/apps/server/scripts/build-sidecar.mjs b/apps/server/scripts/build-sidecar.mjs index 03114d8..0eec3be 100644 --- a/apps/server/scripts/build-sidecar.mjs +++ b/apps/server/scripts/build-sidecar.mjs @@ -1,4 +1,4 @@ -import { mkdir, stat } from 'node:fs/promises'; +import { mkdir, readFile, stat } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -7,6 +7,10 @@ import { build } from 'esbuild'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const output = resolve(packageRoot, 'dist-sidecar', 'app-server.cjs'); +const settingsSchema = await readFile( + resolve(packageRoot, '..', '..', 'packages', 'core', 'schemas', 'settings.schema.json'), + 'utf8', +); await mkdir(dirname(output), { recursive: true }); await build({ entryPoints: [resolve(packageRoot, 'src', 'sidecar-entry.ts')], @@ -18,6 +22,10 @@ await build({ minify: true, sourcemap: false, legalComments: 'none', + define: { + __DEEPCODE_SETTINGS_SCHEMA__: JSON.stringify(settingsSchema), + 'import.meta.url': 'undefined', + }, banner: { js: '#!/usr/bin/env node' }, }); diff --git a/apps/server/src/diagnostic-export.ts b/apps/server/src/diagnostic-export.ts new file mode 100644 index 0000000..a2a56cc --- /dev/null +++ b/apps/server/src/diagnostic-export.ts @@ -0,0 +1,107 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { VERSION } from '@deepcode/core'; +import { + PROTOCOL_VERSION, + type ConfigDiagnosticsResult, + type DiagnosticExportResult, +} from '@deepcode/protocol'; + +import { sanitizeStructuredLogRecord } from './structured-logger.js'; + +export interface DiagnosticExportOptions { + home: string; + cwd: string; + config: ConfigDiagnosticsResult; + generatedAt?: string; + logPath?: string; + maxLogRecords?: number; +} + +/** Create a support bundle that contains no raw paths, settings values, prompts, or tool payloads. */ +export async function exportDiagnosticBundle( + options: DiagnosticExportOptions, +): Promise { + const generatedAt = options.generatedAt ?? new Date().toISOString(); + const records = await readSafeLogRecords(options.logPath, options.maxLogRecords ?? 1000); + const bundle = { + schemaVersion: 1, + generatedAt, + deepcodeVersion: VERSION, + protocolVersion: PROTOCOL_VERSION, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + workspace: { id: pathId(resolve(options.cwd)) }, + configuration: sanitizeConfiguration(options.config), + logs: records, + }; + + const directory = join(options.home, 'diagnostics'); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const stamp = generatedAt.replace(/[^0-9]/g, '').slice(0, 14) || 'unknown'; + const path = join(directory, `deepcode-diagnostics-${stamp}-${randomUUID().slice(0, 8)}.json`); + await writeFile(path, `${JSON.stringify(bundle, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + return { path, generatedAt, recordCount: records.length }; +} + +function sanitizeConfiguration(config: ConfigDiagnosticsResult) { + return { + trustStatus: config.trustStatus, + layers: config.layers.map((layer) => ({ + layer: layer.layer, + present: layer.present, + trusted: layer.trusted, + sourceId: pathId(layer.path), + })), + provenance: Object.entries(config.provenance).map(([pointer, source]) => ({ + pointer, + layer: source.layer, + sourceId: pathId(source.path), + })), + gated: [...config.gated], + issues: config.issues.map((issue) => ({ + severity: issue.severity, + code: safeToken(issue.code), + pointer: issue.pointer, + sourceLayer: issue.source?.layer, + sourceId: issue.source ? pathId(issue.source.path) : undefined, + })), + }; +} + +async function readSafeLogRecords(path: string | undefined, max: number): Promise { + if (!path || max <= 0) return []; + let contents: string; + try { + contents = await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + return contents + .split('\n') + .filter(Boolean) + .slice(-max) + .flatMap((line) => { + try { + const value = JSON.parse(line) as Record; + return [sanitizeStructuredLogRecord(value)]; + } catch { + return []; + } + }); +} + +function pathId(path: string): string { + return createHash('sha256').update(resolve(path)).digest('hex').slice(0, 16); +} + +function safeToken(value: unknown): string { + if (typeof value !== 'string') return 'unknown'; + return value.replace(/[^a-zA-Z0-9._:/-]/g, '_').slice(0, 160) || 'unknown'; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 657db0b..515b0b3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,3 +6,5 @@ export * from './stdio.js'; export * from './run.js'; export * from './client.js'; export * from './runtime-composition.js'; +export * from './structured-logger.js'; +export * from './diagnostic-export.js'; diff --git a/apps/server/src/run.test.ts b/apps/server/src/run.test.ts index e51c1a5..f3e46b6 100644 --- a/apps/server/src/run.test.ts +++ b/apps/server/src/run.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -32,7 +32,8 @@ describe('runAppServer', () => { input.end( `${JSON.stringify({ id: 1, method: 'initialize', params: {} })}\n` + - `${JSON.stringify({ id: 2, method: 'config/diagnostics', params: { cwd } })}\n`, + `${JSON.stringify({ id: 2, method: 'config/diagnostics', params: { cwd } })}\n` + + `${JSON.stringify({ id: 3, method: 'diagnostics/export', params: { cwd } })}\n`, ); await runAppServer({ input, @@ -60,5 +61,10 @@ describe('runAppServer', () => { }), }), ); + const exported = responses[2]?.result as { path: string; recordCount: number }; + expect(exported.recordCount).toBeGreaterThan(0); + const bundle = await readFile(exported.path, 'utf8'); + expect(bundle).toContain('protocol.request.completed'); + expect(bundle).not.toContain(cwd); }); }); diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts index dbaa697..da1ba20 100644 --- a/apps/server/src/run.ts +++ b/apps/server/src/run.ts @@ -8,6 +8,8 @@ import { createDefaultTurnExecutor } from './default-runtime.js'; import { AppServer, type TurnExecutor } from './server.js'; import { CanonicalThreadStore } from './store.js'; import { ProtocolLineWriter, serveStdio } from './stdio.js'; +import { exportDiagnosticBundle } from './diagnostic-export.js'; +import { StructuredLogger } from './structured-logger.js'; export interface RunAppServerOptions { input: Readable; @@ -20,6 +22,13 @@ export interface RunAppServerOptions { export async function runAppServer(options: RunAppServerOptions): Promise { const writer = new ProtocolLineWriter(options.output); const trustStore = new DirectoryTrustStore({ directory: options.home }); + const logger = new StructuredLogger({ directory: join(options.home, 'logs') }); + const diagnosticsFor = async (cwd: string) => + diagnoseSettings({ + cwd, + directory: options.home, + trustStatus: await trustStore.statusFor(cwd), + }); const server = new AppServer({ executor: options.executor ?? @@ -30,16 +39,31 @@ export async function runAppServer(options: RunAppServerOptions): Promise join(options.home, 'threads-v1'), join(options.home, 'sessions'), ), - configDiagnostics: async (cwd) => - diagnoseSettings({ + configDiagnostics: diagnosticsFor, + diagnosticExport: async (cwd) => { + await logger.flush(); + return exportDiagnosticBundle({ + home: options.home, cwd, - directory: options.home, - trustStatus: await trustStore.statusFor(cwd), - }), + config: await diagnosticsFor(cwd), + logPath: logger.path, + }); + }, + onTrace: (record) => { + logger.record( + record, + record.status === 'error' || record.status === 'failed' ? 'error' : 'info', + ); + }, onEvent: (event) => { + logger.recordProtocolEvent(event); const notification: ProtocolNotification = { method: 'event', params: event }; void writer.enqueue(notification); }, }); - await serveStdio(server, options.input, writer); + try { + await serveStdio(server, options.input, writer); + } finally { + await logger.flush(); + } } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index efb2fd1..8906f2f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -33,6 +33,20 @@ function deterministicOptions() { } describe('AppServer', () => { + it('does not let a tracing sink change protocol behavior', async () => { + const server = new AppServer({ + executor: { execute: async () => ({}) }, + onTrace: () => { + throw new Error('trace sink failed'); + }, + }); + + await expect(server.handle(request(1, 'initialize'))).resolves.toEqual({ + id: 1, + result: expect.objectContaining({ protocolVersion: 1 }), + }); + }); + it('advertises and returns value-free configuration diagnostics when provided', async () => { const server = new AppServer({ executor: { execute: async () => ({}) }, @@ -123,6 +137,8 @@ describe('AppServer', () => { const started = await server.handle( request(2, 'turn/start', { threadId: 'thread-1', input: { text: 'hello' } }), ); + const traceId = (started.result as { traceId: string }).traceId; + expect(traceId).toMatch(/^trace-/); expect(started).toEqual({ id: 2, result: expect.objectContaining({ id: 'turn-3', status: 'in_progress' }), @@ -148,6 +164,15 @@ describe('AppServer', () => { expect(events.map((event) => event.type)).toEqual( expect.arrayContaining(['tool.started', 'tool.completed', 'usage.updated']), ); + expect( + events + .filter( + (event) => + ('turnId' in event && event.turnId === 'turn-3') || + ('turn' in event && event.turn.id === 'turn-3'), + ) + .every((event) => event.traceId === traceId), + ).toBe(true); expect((read.result as { turns: Array<{ items: unknown[] }> }).turns[0]?.items).toHaveLength(2); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3d577a0..98bdcb1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,7 @@ import { ProtocolRuntime, type CompletedItemType, type ConfigDiagnosticsResult, + type DiagnosticExportResult, type ProtocolEvent, type ProtocolRequest, type ProtocolResponse, @@ -53,8 +54,25 @@ export interface AppServerOptions { store?: ThreadStore; now?: () => string; newId?: (prefix: 'thread' | 'turn' | 'item') => string; + newTraceId?: () => string; onEvent?: (event: ProtocolEvent) => void; + onTrace?: (record: AppServerTraceRecord) => void; configDiagnostics?: (cwd: string) => Promise; + diagnosticExport?: (cwd: string) => Promise; +} + +/** Strictly metadata-only records; no prompt, tool payload, command, or error message. */ +export interface AppServerTraceRecord { + event: string; + traceId: string; + protocolRequestId?: string | number; + method?: string; + threadId?: string; + turnId?: string; + itemId?: string; + status?: string; + code?: string; + durationMs?: number; } interface ActiveTurn { @@ -85,20 +103,43 @@ export class AppServer { private readonly terminalTransitions = new Map>(); private readonly pendingInteractions = new Map(); private interactionSequence = 0; + private readonly newTraceId: () => string; constructor(private readonly options: AppServerOptions) { + this.newTraceId = + options.newTraceId ?? + (() => `trace-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`); this.lifecycle = new ProtocolRuntime({ store: options.store ?? new MemoryThreadStore(), now: options.now, newId: options.newId, + newTraceId: this.newTraceId, onEvent: options.onEvent, configDiagnostics: options.configDiagnostics !== undefined, + diagnosticExport: options.diagnosticExport !== undefined, }); } async handle(request: ProtocolRequest): Promise { + const traceId = this.newTraceId(); + const startedAt = Date.now(); + this.trace({ + event: 'protocol.request.started', + traceId, + protocolRequestId: request.id, + method: request.method, + }); try { - return { id: request.id, result: await this.dispatch(request) }; + const result = await this.dispatch(request, traceId); + this.trace({ + event: 'protocol.request.completed', + traceId, + protocolRequestId: request.id, + method: request.method, + status: 'ok', + durationMs: Date.now() - startedAt, + }); + return { id: request.id, result }; } catch (error) { const code = error instanceof ProtocolInvariantError @@ -106,6 +147,15 @@ export class AppServer { : error instanceof RequestValidationError ? 'invalid_request' : 'internal_error'; + this.trace({ + event: 'protocol.request.failed', + traceId, + protocolRequestId: request.id, + method: request.method, + status: 'error', + code, + durationMs: Date.now() - startedAt, + }); return { id: request.id, error: { @@ -132,7 +182,7 @@ export class AppServer { await Promise.allSettled(active.map(([, { task }]) => task)); } - private async dispatch(request: ProtocolRequest): Promise { + private async dispatch(request: ProtocolRequest, traceId: string): Promise { switch (request.method) { case 'initialize': return this.lifecycle.initialize(); @@ -141,14 +191,19 @@ export class AppServer { throw new RequestValidationError('Configuration diagnostics are not available'); } return this.options.configDiagnostics(requiredString(request.params, 'cwd')); + case 'diagnostics/export': + if (!this.options.diagnosticExport) { + throw new RequestValidationError('Diagnostic export is not available'); + } + return this.options.diagnosticExport(requiredString(request.params, 'cwd')); case 'thread/start': - return this.lifecycle.startThread(requiredString(request.params, 'cwd')); + return this.lifecycle.startThread(requiredString(request.params, 'cwd'), traceId); 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); + return this.startTurn(request.params, traceId); case 'turn/interrupt': return this.interruptTurn(request.params); case 'approval/respond': @@ -170,11 +225,11 @@ export class AppServer { return thread; } - private async startTurn(params: Record): Promise { + private async startTurn(params: Record, traceId: string): 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 turn = await this.lifecycle.startTurn(threadId, input, traceId); const controller = new AbortController(); const task = this.executeTurn(thread, turn, input, controller); this.activeTurns.set(turn.id, { threadId, controller, task }); @@ -202,6 +257,15 @@ export class AppServer { input: Record, controller: AbortController, ): Promise { + const traceId = turn.traceId ?? this.newTraceId(); + const startedAt = Date.now(); + this.trace({ + event: 'turn.execution.started', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'in_progress', + }); try { const result = await this.options.executor.execute({ thread, @@ -210,6 +274,7 @@ export class AppServer { signal: controller.signal, publishDelta: (itemId, delta) => { this.lifecycle.publishDelta({ + traceId, threadId: thread.id, turnId: turn.id, itemId, @@ -219,6 +284,7 @@ export class AppServer { publishToolStarted: (itemId, name, input) => { this.options.onEvent?.({ type: 'tool.started', + traceId, threadId: thread.id, turnId: turn.id, itemId, @@ -229,6 +295,7 @@ export class AppServer { publishToolCompleted: (itemId, result) => { this.options.onEvent?.({ type: 'tool.completed', + traceId, threadId: thread.id, turnId: turn.id, itemId, @@ -238,17 +305,26 @@ export class AppServer { publishUsage: (usage) => { this.options.onEvent?.({ type: 'usage.updated', + traceId, 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), + this.requestApproval(traceId, thread.id, turn.id, toolName, reason), + requestUserInput: (request) => this.requestUserInput(traceId, thread.id, turn.id, request), }); if (controller.signal.aborted) { await this.finishOnce(turn.id, () => this.lifecycle.interruptTurn(thread.id, turn.id)); + this.trace({ + event: 'turn.execution.completed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'interrupted', + durationMs: Date.now() - startedAt, + }); return; } for (const item of result.items ?? []) { @@ -256,8 +332,24 @@ export class AppServer { } if (result.status === 'failed') { await this.finishOnce(turn.id, () => this.lifecycle.failTurn(thread.id, turn.id)); + this.trace({ + event: 'turn.execution.completed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'failed', + durationMs: Date.now() - startedAt, + }); } else { await this.finishOnce(turn.id, () => this.lifecycle.completeTurn(thread.id, turn.id)); + this.trace({ + event: 'turn.execution.completed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'completed', + durationMs: Date.now() - startedAt, + }); } } catch (error) { if (controller.signal.aborted || (error as Error).name === 'AbortError') { @@ -268,6 +360,16 @@ export class AppServer { }); await this.finishOnce(turn.id, () => this.lifecycle.failTurn(thread.id, turn.id)); } + const interrupted = controller.signal.aborted || (error as Error).name === 'AbortError'; + this.trace({ + event: interrupted ? 'turn.execution.completed' : 'turn.execution.failed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: interrupted ? 'interrupted' : 'failed', + code: errorCode(error), + durationMs: Date.now() - startedAt, + }); } finally { this.cancelInteractions(turn.id); this.activeTurns.delete(turn.id); @@ -275,6 +377,7 @@ export class AppServer { } private requestApproval( + traceId: string, threadId: string, turnId: string, toolName: string, @@ -291,6 +394,7 @@ export class AppServer { }); this.options.onEvent?.({ type: 'approval.requested', + traceId, threadId, turnId, requestId, @@ -301,6 +405,7 @@ export class AppServer { } private requestUserInput( + traceId: string, threadId: string, turnId: string, request: { @@ -320,6 +425,7 @@ export class AppServer { }); this.options.onEvent?.({ type: 'user-input.requested', + traceId, threadId, turnId, requestId, @@ -379,6 +485,14 @@ export class AppServer { return `request-${Date.now().toString(36)}-${++this.interactionSequence}`; } + private trace(record: AppServerTraceRecord): void { + try { + this.options.onTrace?.(record); + } catch { + // Observability must never change protocol or execution behavior. + } + } + private finishOnce( turnId: string, transition: () => Promise, @@ -397,6 +511,13 @@ export class AppServer { } } +function errorCode(error: unknown): string { + if (error instanceof ProtocolInvariantError) return 'invalid_state'; + if (error instanceof RequestValidationError) return 'invalid_request'; + if (error instanceof Error && error.name === 'AbortError') return 'aborted'; + return 'internal_error'; +} + function requiredString(params: Record, key: string): string { const value = params[key]; if (typeof value !== 'string' || value.length === 0) { diff --git a/apps/server/src/structured-logger.test.ts b/apps/server/src/structured-logger.test.ts new file mode 100644 index 0000000..f1b63e6 --- /dev/null +++ b/apps/server/src/structured-logger.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { exportDiagnosticBundle } from './diagnostic-export.js'; +import { StructuredLogger } from './structured-logger.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'deepcode-trace-')); + roots.push(root); + return root; +} + +describe('StructuredLogger', () => { + it('persists only allowlisted metadata even when a caller passes secrets', async () => { + const root = await temporaryRoot(); + const logger = new StructuredLogger({ + directory: join(root, 'logs'), + now: () => '2026-08-01T00:00:00.000Z', + }); + logger.record({ + event: 'protocol.request.failed', + traceId: 'trace-1', + method: 'turn/start', + code: 'internal_error', + prompt: 'SECRET_PROMPT', + command: 'curl -H Authorization:SECRET_HEADER', + message: 'SECRET_ERROR', + } as never); + logger.recordProtocolEvent({ + type: 'tool.started', + traceId: 'trace-1', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + name: 'Bash', + input: { command: 'SECRET_TOOL_INPUT' }, + }); + await logger.flush(); + + const contents = await readFile(logger.path, 'utf8'); + expect(contents).toContain('trace-1'); + expect(contents).toContain('tool.started'); + expect(contents).not.toMatch(/SECRET_|Authorization|curl/); + expect((await stat(logger.path)).mode & 0o777).toBe(0o600); + }); + + it('rotates bounded log files', async () => { + const root = await temporaryRoot(); + const logger = new StructuredLogger({ + directory: join(root, 'logs'), + maxBytes: 1, + retainedFiles: 2, + }); + logger.record({ event: 'first', traceId: 'trace-1' }); + logger.record({ event: 'second', traceId: 'trace-2' }); + logger.record({ event: 'third', traceId: 'trace-3' }); + await logger.flush(); + + await expect(readFile(logger.path, 'utf8')).resolves.toContain('trace-3'); + await expect(readFile(`${logger.path}.1`, 'utf8')).resolves.toContain('trace-2'); + await expect(readFile(`${logger.path}.2`, 'utf8')).resolves.toContain('trace-1'); + }); +}); + +describe('exportDiagnosticBundle', () => { + it('hashes paths and re-sanitizes stored log records', async () => { + const root = await temporaryRoot(); + const cwd = join(root, 'secret-customer-workspace'); + const sourcePath = join(cwd, '.deepcode', 'settings.json'); + const logPath = join(root, 'malicious.ndjson'); + await writeFile( + logPath, + `${JSON.stringify({ + timestamp: '2026-08-01T00:00:00.000Z', + level: 'info', + event: 'protocol.event', + traceId: 'trace-1', + method: 'SECRET_METHOD', + threadId: 'SECRET_THREAD_ID', + status: 'SECRET_STATUS', + message: 'SECRET_LOG_MESSAGE', + payload: { token: 'SECRET_TOKEN' }, + })}\n`, + ); + const config: ConfigDiagnosticsResult = { + cwd, + trustStatus: 'untrusted', + layers: [{ layer: 'project', path: sourcePath, present: true, trusted: false }], + provenance: { '/model': { layer: 'project', path: sourcePath } }, + gated: ['/permissions'], + issues: [ + { + severity: 'warning', + code: 'secret_setting', + message: 'SECRET_ISSUE_MESSAGE', + source: { layer: 'project', path: sourcePath }, + }, + ], + }; + + const result = await exportDiagnosticBundle({ + home: root, + cwd, + config, + logPath, + generatedAt: '2026-08-01T00:00:00.000Z', + }); + const contents = await readFile(result.path, 'utf8'); + expect(result.recordCount).toBe(1); + expect(contents).toContain('secret_setting'); + expect(contents).not.toMatch( + /secret-customer-workspace|settings\.json|SECRET_|SECRET_TOKEN|SECRET_ISSUE_MESSAGE/, + ); + expect((await stat(result.path)).mode & 0o777).toBe(0o600); + }); +}); diff --git a/apps/server/src/structured-logger.ts b/apps/server/src/structured-logger.ts new file mode 100644 index 0000000..debe896 --- /dev/null +++ b/apps/server/src/structured-logger.ts @@ -0,0 +1,271 @@ +import { createHash } from 'node:crypto'; +import { appendFile, chmod, mkdir, rename, stat, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import type { ProtocolEvent } from '@deepcode/protocol'; + +import type { AppServerTraceRecord } from './server.js'; + +export interface StructuredLoggerOptions { + directory: string; + now?: () => string; + maxBytes?: number; + retainedFiles?: number; +} + +export interface StructuredLogRecord extends AppServerTraceRecord { + schemaVersion: 1; + timestamp: string; + level: 'info' | 'warning' | 'error'; +} + +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; +const DEFAULT_RETAINED_FILES = 3; +const TRACE_EVENTS = new Set([ + 'protocol.request.started', + 'protocol.request.completed', + 'protocol.request.failed', + 'protocol.event', + 'turn.execution.started', + 'turn.execution.completed', + 'turn.execution.failed', +]); +const PROTOCOL_METHODS = new Set([ + 'initialize', + 'config/diagnostics', + 'diagnostics/export', + 'thread/start', + 'thread/read', + 'thread/resume', + 'turn/start', + 'turn/interrupt', + 'approval/respond', + 'user-input/respond', +]); +const STATUSES = new Set([ + 'ok', + 'error', + 'in_progress', + 'completed', + 'failed', + 'interrupted', + 'thread.started', + 'turn.started', + 'item.completed', + 'turn.completed', + 'turn.interrupted', + 'turn.failed', + 'item.delta', + 'tool.started', + 'tool.completed', + 'usage.updated', + 'approval.requested', + 'user-input.requested', +]); +const ERROR_CODES = new Set(['invalid_state', 'invalid_request', 'aborted', 'internal_error']); + +/** + * Bounded, best-effort NDJSON logging for the app-server trust boundary. + * `record` rebuilds a value from a strict allowlist instead of serializing its + * argument, so prompts, tool payloads, commands, and error messages cannot be + * persisted accidentally. + */ +export class StructuredLogger { + readonly path: string; + private readonly now: () => string; + private readonly maxBytes: number; + private readonly retainedFiles: number; + private tail = Promise.resolve(); + private lastError: Error | undefined; + + constructor(options: StructuredLoggerOptions) { + this.path = join(options.directory, 'app-server.ndjson'); + this.now = options.now ?? (() => new Date().toISOString()); + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.retainedFiles = options.retainedFiles ?? DEFAULT_RETAINED_FILES; + } + + record(input: AppServerTraceRecord, level: StructuredLogRecord['level'] = 'info'): void { + const record = normalizeRecord(input, level, this.now()); + const line = `${JSON.stringify(record)}\n`; + this.tail = this.tail + .then(() => this.append(line)) + .catch((error: unknown) => { + this.lastError = error instanceof Error ? error : new Error(String(error)); + }); + } + + recordProtocolEvent(event: ProtocolEvent): void { + const record = traceRecordForProtocolEvent(event); + if (record) this.record(record); + } + + async flush(): Promise { + await this.tail; + } + + error(): Error | undefined { + return this.lastError; + } + + private async append(line: string): Promise { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + await this.rotateIfNeeded(Buffer.byteLength(line)); + await appendFile(this.path, line, { encoding: 'utf8', mode: 0o600 }); + await chmod(this.path, 0o600); + } + + private async rotateIfNeeded(incomingBytes: number): Promise { + let currentBytes = 0; + try { + currentBytes = (await stat(this.path)).size; + } catch (error) { + if (!isMissing(error)) throw error; + } + if (currentBytes === 0 || currentBytes + incomingBytes <= this.maxBytes) return; + + if (this.retainedFiles > 0) { + await ignoreMissing(() => unlink(`${this.path}.${this.retainedFiles}`)); + for (let index = this.retainedFiles - 1; index >= 1; index--) { + await ignoreMissing(() => rename(`${this.path}.${index}`, `${this.path}.${index + 1}`)); + } + await ignoreMissing(() => rename(this.path, `${this.path}.1`)); + } else { + await ignoreMissing(() => unlink(this.path)); + } + } +} + +function normalizeRecord( + input: AppServerTraceRecord, + level: StructuredLogRecord['level'], + timestamp: string, +): StructuredLogRecord { + const record: StructuredLogRecord = { + schemaVersion: 1, + timestamp, + level, + event: allowedValue(input.event, TRACE_EVENTS), + traceId: safeIdentifier(input.traceId, ['trace-']), + }; + if ( + typeof input.protocolRequestId === 'number' && + Number.isSafeInteger(input.protocolRequestId) + ) { + record.protocolRequestId = input.protocolRequestId; + } else if (typeof input.protocolRequestId === 'string') { + record.protocolRequestId = opaqueIdentifier(input.protocolRequestId); + } + if (input.method) record.method = allowedValue(input.method, PROTOCOL_METHODS); + if (input.threadId) record.threadId = safeIdentifier(input.threadId, ['thread-', 'legacy-']); + if (input.turnId) record.turnId = safeIdentifier(input.turnId, ['turn-', 'legacy-']); + if (input.itemId) { + record.itemId = safeIdentifier(input.itemId, ['item-', 'call_', 'tool-', 'legacy-item-']); + } + if (input.status) record.status = allowedValue(input.status, STATUSES); + if (input.code) record.code = allowedValue(input.code, ERROR_CODES); + if (typeof input.durationMs === 'number' && Number.isFinite(input.durationMs)) { + record.durationMs = Math.max(0, Math.round(input.durationMs)); + } + return record; +} + +/** Rebuild a record read from disk through the same strict schema used on write. */ +export function sanitizeStructuredLogRecord(value: Record): StructuredLogRecord { + const input: AppServerTraceRecord = { + event: typeof value.event === 'string' ? value.event : 'unknown', + traceId: typeof value.traceId === 'string' ? value.traceId : 'unknown', + }; + if ( + typeof value.protocolRequestId === 'string' || + (typeof value.protocolRequestId === 'number' && Number.isSafeInteger(value.protocolRequestId)) + ) { + input.protocolRequestId = value.protocolRequestId; + } + for (const key of ['method', 'threadId', 'turnId', 'itemId', 'status', 'code'] as const) { + if (typeof value[key] === 'string') input[key] = value[key]; + } + if (typeof value.durationMs === 'number' && Number.isFinite(value.durationMs)) { + input.durationMs = value.durationMs; + } + const level = + value.level === 'warning' || value.level === 'error' || value.level === 'info' + ? value.level + : 'error'; + return normalizeRecord(input, level, safeTimestamp(value.timestamp)); +} + +function allowedValue(value: string, choices: ReadonlySet): string { + return choices.has(value) ? value : 'unknown'; +} + +function safeIdentifier(value: string, prefixes: string[]): string { + if ( + value.length <= 160 && + /^[a-zA-Z0-9._-]+$/.test(value) && + prefixes.some((prefix) => value.startsWith(prefix)) + ) { + return value; + } + return opaqueIdentifier(value); +} + +function opaqueIdentifier(value: string): string { + return `hash-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; +} + +function safeTimestamp(value: unknown): string { + if (typeof value !== 'string') return 'unknown'; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : 'unknown'; +} + +function traceRecordForProtocolEvent(event: ProtocolEvent): AppServerTraceRecord | null { + const traceId = event.traceId; + if (!traceId) return null; + switch (event.type) { + case 'thread.started': + return { event: 'protocol.event', traceId, threadId: event.thread.id, status: event.type }; + case 'turn.started': + case 'turn.completed': + case 'turn.interrupted': + case 'turn.failed': + return { + event: 'protocol.event', + traceId, + threadId: event.threadId, + turnId: event.turn.id, + status: event.type, + }; + case 'item.completed': + return { + event: 'protocol.event', + traceId, + threadId: event.threadId, + turnId: event.turnId, + itemId: event.item.id, + status: event.type, + }; + default: + return { + event: 'protocol.event', + traceId, + threadId: event.threadId, + turnId: event.turnId, + itemId: 'itemId' in event ? event.itemId : undefined, + status: event.type, + }; + } +} + +async function ignoreMissing(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + if (!isMissing(error)) throw error; + } +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} diff --git a/apps/vscode/scripts/build.mjs b/apps/vscode/scripts/build.mjs index 84b2984..d75ccdf 100644 --- a/apps/vscode/scripts/build.mjs +++ b/apps/vscode/scripts/build.mjs @@ -1,4 +1,4 @@ -import { mkdir, stat } from 'node:fs/promises'; +import { mkdir, readFile, stat } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -7,6 +7,10 @@ import { build } from 'esbuild'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const outputRoot = resolve(packageRoot, 'dist'); +const settingsSchema = await readFile( + resolve(packageRoot, '..', '..', 'packages', 'core', 'schemas', 'settings.schema.json'), + 'utf8', +); await mkdir(outputRoot, { recursive: true }); await Promise.all([ @@ -19,6 +23,7 @@ await Promise.all([ target: 'node22', external: ['vscode'], define: { + __DEEPCODE_SETTINGS_SCHEMA__: JSON.stringify(settingsSchema), 'import.meta.url': '__deepcode_import_meta_url', }, banner: { @@ -37,6 +42,10 @@ await Promise.all([ minify: true, sourcemap: false, legalComments: 'none', + define: { + __DEEPCODE_SETTINGS_SCHEMA__: JSON.stringify(settingsSchema), + 'import.meta.url': 'undefined', + }, }), ]); diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index c9f3dd0..628b923 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -40,6 +40,7 @@ class FakeClient { structuredToolEvents: true, interactiveRequests: true, configDiagnostics: true, + diagnosticExport: true, }, }; } diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 08329a3..77046cd 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -326,8 +326,12 @@ model tool call - project/local command hook 已收敛到规范化定义哈希审核:未审阅或已变化的定义默认跳过, `deepcode hooks list|trust |revoke` 提供显式管理,diagnostics 暴露来源与审核状态; 目录 trust 只是第一道门,user/explicit override 仍是可信层。 +- app-server 已为 turn 生成稳定 `traceId` 并贯穿 durable/transient 事件;有界 NDJSON 只允许 + 关联 ID、事件、状态码与耗时,不序列化协议 payload。`diagnostics/export` 与 CLI 共用脱敏器, + 路径哈希化、配置值/issue message 省略,导出前再次白名单清洗;删除 `logs/`/`diagnostics/` + 即可回滚,不影响 canonical thread。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 -- diff review、可定位反馈、trace id、结构化日志与脱敏导出。 +- diff review、可定位反馈。 - 删除完成迁移的旧 IPC/facade;更新所有用户文档。 - release candidate、迁移演练、性能预算和回滚说明。 diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index f944aab..9805686 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -13,23 +13,29 @@ import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Schema file is at /schemas/settings.schema.json -// From dist/config/schema.js the relative path is ../../schemas/... -// From src/config/schema.ts the relative path is ../../schemas/... too. -const SCHEMA_PATH = join(__dirname, '..', '..', 'schemas', 'settings.schema.json'); +// CJS sidecars cannot resolve package-relative assets through import.meta.url, +// and packaged clients do not ship the core workspace tree. Their esbuild +// entrypoints replace this constant with the schema contents. Normal ESM +// package consumers take the file-backed fallback below. +declare const __DEEPCODE_SETTINGS_SCHEMA__: string | undefined; +const EMBEDDED_SCHEMA = + typeof __DEEPCODE_SETTINGS_SCHEMA__ === 'string' ? __DEEPCODE_SETTINGS_SCHEMA__ : undefined; let cached: string | undefined; export async function settingsSchemaJson(): Promise { if (cached === undefined) { - cached = await readFile(SCHEMA_PATH, 'utf8'); + cached = EMBEDDED_SCHEMA ?? (await readFile(schemaPath(), 'utf8')); } return cached; } +function schemaPath(): string { + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); + // From both src/config/schema.ts and dist/config/schema.js. + return join(moduleDirectory, '..', '..', 'schemas', 'settings.schema.json'); +} + export async function settingsSchemaObject(): Promise> { const raw = await settingsSchemaJson(); return JSON.parse(raw) as Record; diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 4e304d1..84c8950 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -8,4 +8,12 @@ activity, and interactive approval/user-input requests are transient and exclude 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. +`initialize` instead of assuming backwards compatibility. The contract also covers configuration +diagnostics and redacted diagnostic export. + +New turns carry a host-generated optional `traceId` (optional so pre-tracing snapshots remain +readable). The same id is attached to every event for that turn. Consumers must treat it as an +opaque correlation value, not as authorization or a persistence key. + +`diagnostics/export` is capability-negotiated. It returns only the local bundle path, generation +time, and record count; the app-server owns path hashing and payload redaction. diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts index b4cbcd9..2358eb6 100644 --- a/packages/protocol/src/codec.test.ts +++ b/packages/protocol/src/codec.test.ts @@ -33,16 +33,18 @@ describe('protocol codec', () => { ); }); - 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({ - id: 2, - method, - params: {}, - }); - }, - ); + it.each([ + 'approval/respond', + 'user-input/respond', + 'config/diagnostics', + 'diagnostics/export', + ] 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', diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index 7324747..fe48001 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -8,6 +8,7 @@ import type { const protocolMethods = new Set([ 'initialize', 'config/diagnostics', + 'diagnostics/export', 'thread/start', 'thread/read', 'thread/resume', diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts index cc69828..fa54a7a 100644 --- a/packages/protocol/src/runtime.test.ts +++ b/packages/protocol/src/runtime.test.ts @@ -36,6 +36,7 @@ describe('ProtocolRuntime', () => { structuredToolEvents: true, interactiveRequests: true, configDiagnostics: false, + diagnosticExport: false, }, }); }); @@ -46,12 +47,14 @@ describe('ProtocolRuntime', () => { const runtime = deterministicRuntime(store, events); const thread = await runtime.startThread('/workspace'); const turn = await runtime.startTurn(thread.id, { text: 'inspect the repository' }); + expect(turn.traceId).toMatch(/^trace-/); const assistant = await runtime.appendCompletedItem(thread.id, turn.id, 'assistant_message', { text: 'working', }); const savesBeforeDelta = store.saveCount; runtime.publishDelta({ + traceId: turn.traceId, threadId: thread.id, turnId: turn.id, itemId: assistant.id, @@ -77,6 +80,11 @@ describe('ProtocolRuntime', () => { 'item.delta', 'turn.completed', ]); + expect( + events + .filter((event) => event.type !== 'thread.started') + .every((event) => event.traceId === turn.traceId), + ).toBe(true); }); it('allows only one active turn per thread', async () => { diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index 47e3689..a122b32 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -39,8 +39,10 @@ export interface ProtocolRuntimeOptions { store: ThreadStore; now?: () => string; newId?: (prefix: 'thread' | 'turn' | 'item') => string; + newTraceId?: () => string; onEvent?: (event: ProtocolEvent) => void; configDiagnostics?: boolean; + diagnosticExport?: boolean; } export class ProtocolInvariantError extends Error { @@ -53,6 +55,7 @@ export class ProtocolInvariantError extends Error { export class ProtocolRuntime { private readonly now: () => string; private readonly newId: (prefix: 'thread' | 'turn' | 'item') => string; + private readonly newTraceId: () => string; constructor(private readonly options: ProtocolRuntimeOptions) { this.now = options.now ?? (() => new Date().toISOString()); @@ -60,6 +63,9 @@ export class ProtocolRuntime { options.newId ?? ((prefix) => `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`); + this.newTraceId = + options.newTraceId ?? + (() => `trace-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`); } initialize(): InitializeResult { @@ -73,11 +79,12 @@ export class ProtocolRuntime { structuredToolEvents: true, interactiveRequests: true, configDiagnostics: this.options.configDiagnostics ?? false, + diagnosticExport: this.options.diagnosticExport ?? false, }, }; } - async startThread(cwd: string): Promise { + async startThread(cwd: string, traceId?: string): Promise { const now = this.now(); const thread: ThreadSnapshot = { id: this.newId('thread'), @@ -87,7 +94,7 @@ export class ProtocolRuntime { turns: [], }; await this.options.store.save(thread); - this.emit({ type: 'thread.started', thread: clone(thread) }); + this.emit({ type: 'thread.started', traceId, thread: clone(thread) }); return clone(thread); } @@ -99,7 +106,11 @@ export class ProtocolRuntime { return this.requireThread(threadId); } - async startTurn(threadId: string, input: Record): Promise { + async startTurn( + threadId: string, + input: Record, + traceId = this.newTraceId(), + ): 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`); @@ -108,6 +119,7 @@ export class ProtocolRuntime { const inputItem = this.completedItem('user_message', input, now); const turn: TurnSnapshot = { id: this.newId('turn'), + traceId, threadId, status: 'in_progress', startedAt: now, @@ -116,8 +128,14 @@ export class ProtocolRuntime { 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) }); + this.emit({ type: 'turn.started', traceId, threadId, turn: clone(turn) }); + this.emit({ + type: 'item.completed', + traceId, + threadId, + turnId: turn.id, + item: clone(inputItem), + }); return clone(turn); } @@ -136,7 +154,13 @@ export class ProtocolRuntime { turn.items.push(item); thread.updatedAt = item.completedAt; await this.options.store.save(thread); - this.emit({ type: 'item.completed', threadId, turnId, item: clone(item) }); + this.emit({ + type: 'item.completed', + traceId: turn.traceId, + threadId, + turnId, + item: clone(item), + }); return clone(item); } @@ -175,7 +199,7 @@ export class ProtocolRuntime { : requested === 'interrupted' ? 'turn.interrupted' : 'turn.failed'; - this.emit({ type, threadId, turn: clone(turn) } as DurableProtocolEvent); + this.emit({ type, traceId: turn.traceId, threadId, turn: clone(turn) } as DurableProtocolEvent); return clone(turn); } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 8363c3f..a5955c9 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -19,6 +19,8 @@ export interface CompletedItem { export interface TurnSnapshot { id: string; + /** Host-generated correlation id. Optional when reading pre-tracing snapshots. */ + traceId?: string; threadId: string; status: TurnStatus; startedAt: string; @@ -35,15 +37,22 @@ export interface ThreadSnapshot { } 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 }; + | { type: 'thread.started'; traceId?: string; thread: ThreadSnapshot } + | { type: 'turn.started'; traceId?: string; threadId: string; turn: TurnSnapshot } + | { + type: 'item.completed'; + traceId?: string; + threadId: string; + turnId: string; + item: CompletedItem; + } + | { type: 'turn.completed'; traceId?: string; threadId: string; turn: TurnSnapshot } + | { type: 'turn.interrupted'; traceId?: string; threadId: string; turn: TurnSnapshot } + | { type: 'turn.failed'; traceId?: string; threadId: string; turn: TurnSnapshot }; export interface ToolStartedEvent { type: 'tool.started'; + traceId?: string; threadId: string; turnId: string; itemId: string; @@ -53,6 +62,7 @@ export interface ToolStartedEvent { export interface ToolCompletedEvent { type: 'tool.completed'; + traceId?: string; threadId: string; turnId: string; itemId: string; @@ -61,6 +71,7 @@ export interface ToolCompletedEvent { export interface UsageUpdatedEvent { type: 'usage.updated'; + traceId?: string; threadId: string; turnId: string; usage: { @@ -73,6 +84,7 @@ export interface UsageUpdatedEvent { export interface ApprovalRequestedEvent { type: 'approval.requested'; + traceId?: string; threadId: string; turnId: string; requestId: string; @@ -82,6 +94,7 @@ export interface ApprovalRequestedEvent { export interface UserInputRequestedEvent { type: 'user-input.requested'; + traceId?: string; threadId: string; turnId: string; requestId: string; @@ -92,6 +105,7 @@ export interface UserInputRequestedEvent { export interface TransientDeltaEvent { type: 'item.delta'; + traceId?: string; threadId: string; turnId: string; itemId: string; @@ -118,6 +132,7 @@ export interface InitializeResult { structuredToolEvents: true; interactiveRequests: true; configDiagnostics: boolean; + diagnosticExport: boolean; }; } @@ -143,9 +158,16 @@ export interface ConfigDiagnosticsResult { }>; } +export interface DiagnosticExportResult { + path: string; + generatedAt: string; + recordCount: number; +} + export type ProtocolMethod = | 'initialize' | 'config/diagnostics' + | 'diagnostics/export' | 'thread/start' | 'thread/read' | 'thread/resume' From a60f084b71a9c6e8d5288d217de65edd502dd83e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 16:57:58 +0800 Subject: [PATCH 27/33] feat: centralize workspace diff review --- apps/desktop/src/lib/protocol-agent.test.ts | 22 +++ apps/desktop/src/lib/protocol-agent.ts | 14 ++ apps/lsp/src/handler.test.ts | 24 +++ apps/lsp/src/handler.ts | 14 ++ apps/server/README.md | 5 + apps/server/src/index.ts | 1 + apps/server/src/run.ts | 2 + apps/server/src/server.test.ts | 22 ++- apps/server/src/server.ts | 10 + apps/server/src/structured-logger.ts | 1 + apps/server/src/workspace-diff.test.ts | 91 +++++++++ apps/server/src/workspace-diff.ts | 208 ++++++++++++++++++++ apps/vscode/README.md | 2 +- apps/vscode/src/extension.ts | 25 ++- apps/vscode/src/protocol-runtime.test.ts | 22 +++ apps/vscode/src/protocol-runtime.ts | 10 + apps/vscode/src/workspace-diff.test.ts | 39 ++++ apps/vscode/src/workspace-diff.ts | 25 +++ docs/CODEX_ALIGNMENT_PLAN.md | 5 +- packages/protocol/README.md | 3 + packages/protocol/src/codec.test.ts | 1 + packages/protocol/src/codec.ts | 1 + packages/protocol/src/runtime.test.ts | 1 + packages/protocol/src/runtime.ts | 2 + packages/protocol/src/types.ts | 38 ++++ 25 files changed, 579 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/workspace-diff.test.ts create mode 100644 apps/server/src/workspace-diff.ts create mode 100644 apps/vscode/src/workspace-diff.test.ts create mode 100644 apps/vscode/src/workspace-diff.ts diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index 232e6f6..e7311b3 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -5,6 +5,7 @@ import type { ProtocolMethod, ThreadSnapshot, TurnSnapshot, + WorkspaceDiffResult, } from '@deepcode/protocol'; import { describe, expect, it, vi } from 'vitest'; @@ -26,6 +27,7 @@ class FakeTransport implements ProtocolTransport { interactiveRequests: true, configDiagnostics: true, diagnosticExport: true, + workspaceDiff: true, }, }; } @@ -44,6 +46,7 @@ class FakeTransport implements ProtocolTransport { 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; + if (method === 'workspace/diff') return workspaceDiff as T; return { accepted: true } as T; } } @@ -57,6 +60,13 @@ const diagnostics: ConfigDiagnosticsResult = { issues: [], }; +const workspaceDiff: WorkspaceDiffResult = { + repository: true, + base: 'HEAD', + files: [], + truncated: false, +}; + const thread: ThreadSnapshot = { id: 'thread-1', cwd: '/workspace', @@ -74,6 +84,18 @@ const turn: TurnSnapshot = { }; describe('DesktopProtocolAgent', () => { + it('reads workspace diff only through an adopted canonical thread', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await expect(agent.diff()).rejects.toThrow('No active workspace thread'); + await agent.resume(thread.id); + await expect(agent.diff()).resolves.toEqual(workspaceDiff); + expect(transport.requests.at(-1)).toEqual({ + method: 'workspace/diff', + params: { threadId: thread.id }, + }); + }); + it('reads value-free diagnostics from the shared app-server', async () => { const transport = new FakeTransport(); const agent = new DesktopProtocolAgent(transport, () => undefined); diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts index 24d4424..1cc7fcf 100644 --- a/apps/desktop/src/lib/protocol-agent.ts +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -5,6 +5,7 @@ import type { ProtocolMethod, ThreadSnapshot, TurnSnapshot, + WorkspaceDiffResult, } from '@deepcode/protocol'; import { setActiveSessionId } from './mac-session.js'; @@ -92,6 +93,15 @@ export class DesktopProtocolAgent { return this.transport.request('config/diagnostics', { cwd }); } + async diff(): Promise { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.workspaceDiff) { + throw new Error('The app-server does not support workspace diff'); + } + if (!this.threadId) throw new Error('No active workspace thread'); + return this.transport.request('workspace/diff', { threadId: this.threadId }); + } + clear(): void { void this.interruptActiveTurns(); this.threadId = null; @@ -306,6 +316,10 @@ export function getConfigDiagnostics(cwd: string) { return defaultAgent.diagnostics(cwd); } +export function getWorkspaceDiff() { + return defaultAgent.diff(); +} + export function abortProtocolTurn(turnId: string) { return defaultAgent.abort(turnId); } diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index 9b2f7f8..fff11ea 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -6,6 +6,7 @@ import type { ProtocolRequest, ThreadSnapshot, TurnSnapshot, + WorkspaceDiffResult, } from '@deepcode/protocol'; import { afterEach, describe, expect, it } from 'vitest'; @@ -22,6 +23,7 @@ const capabilities: InitializeResult = { interactiveRequests: true, configDiagnostics: true, diagnosticExport: true, + workspaceDiff: true, }, }; @@ -34,6 +36,13 @@ const diagnostics: ConfigDiagnosticsResult = { issues: [], }; +const workspaceDiff: WorkspaceDiffResult = { + repository: true, + base: 'HEAD', + files: [], + truncated: false, +}; + class FakeClient { subscribers = new Set<(event: ProtocolEvent) => void>(); requests: ProtocolRequest[] = []; @@ -107,6 +116,8 @@ class FakeClient { return { accepted: true } as T; case 'config/diagnostics': return diagnostics as T; + case 'workspace/diff': + return workspaceDiff as T; default: throw new Error(`Unexpected method: ${method}`); } @@ -146,6 +157,7 @@ describe('handleMessage — initialize', () => { 'deepcode.respondApproval', 'deepcode.respondUserInput', 'deepcode.configDiagnostics', + 'deepcode.workspaceDiff', ]), ); }); @@ -170,6 +182,18 @@ describe('handleMessage — protocol commands', () => { }); }); + it('returns the canonical workspace diff through the current thread', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + await execute(20, 'deepcode.workspaceDiff', {}, (message) => out.push(message)); + expect(out.find((message) => message.id === 20)?.result).toEqual(workspaceDiff); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/start', + 'workspace/diff', + ]); + }); + 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 27a7ac9..51ce3c4 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -11,6 +11,7 @@ import { type ProtocolMethod, type ThreadSnapshot, type TurnSnapshot, + type WorkspaceDiffResult, } from '@deepcode/protocol'; export interface LspMessage { @@ -66,6 +67,7 @@ const COMMANDS = [ 'deepcode.respondUserInput', 'deepcode.listSkills', 'deepcode.configDiagnostics', + 'deepcode.workspaceDiff', ]; export async function handleMessage(msg: LspMessage, send: SendFn): Promise { @@ -170,6 +172,8 @@ async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): return handleListSkills(); case 'deepcode.configDiagnostics': return handleConfigDiagnostics(); + case 'deepcode.workspaceDiff': + return handleWorkspaceDiff(); default: throw new Error(`Unknown command: ${params.command}`); } @@ -401,6 +405,16 @@ async function handleConfigDiagnostics(): Promise { return client.request('config/diagnostics', { cwd: workspacePath() }); } +async function handleWorkspaceDiff(): Promise { + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.workspaceDiff) { + throw new Error('The app-server does not support workspace diff'); + } + const thread = await ensureThread(client); + return client.request('workspace/diff', { threadId: thread.id }); +} + export const __test = { state, dispatch, diff --git a/apps/server/README.md b/apps/server/README.md index 0191d31..91cffea 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -42,3 +42,8 @@ tool arguments/results, or error messages. `diagnostics/export` (also available hashes workspace/config paths, omits issue messages and configuration values, re-sanitizes every log record, and can be removed without affecting threads. Removing `logs/` and `diagnostics/` is the rollback for this optional observability layer. + +`workspace/diff` is bound to a canonical `threadId`, so clients cannot substitute an unrelated cwd. +The server invokes Git without a shell and returns a bounded file/hunk/line DTO for tracked and +untracked changes. Untracked symlinks and binary contents are never read into the response. Desktop, +VS Code, and LSP consume this same capability; clients do not parse Git output independently. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 515b0b3..5c73e70 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -8,3 +8,4 @@ export * from './client.js'; export * from './runtime-composition.js'; export * from './structured-logger.js'; export * from './diagnostic-export.js'; +export * from './workspace-diff.js'; diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts index da1ba20..b01a791 100644 --- a/apps/server/src/run.ts +++ b/apps/server/src/run.ts @@ -10,6 +10,7 @@ import { CanonicalThreadStore } from './store.js'; import { ProtocolLineWriter, serveStdio } from './stdio.js'; import { exportDiagnosticBundle } from './diagnostic-export.js'; import { StructuredLogger } from './structured-logger.js'; +import { collectWorkspaceDiff } from './workspace-diff.js'; export interface RunAppServerOptions { input: Readable; @@ -49,6 +50,7 @@ export async function runAppServer(options: RunAppServerOptions): Promise logPath: logger.path, }); }, + workspaceDiff: collectWorkspaceDiff, onTrace: (record) => { logger.record( record, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8906f2f..679d453 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3,7 +3,7 @@ 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 { afterEach, describe, expect, it, vi } from 'vitest'; import { AppServer, type TurnExecutor } from './server.js'; import { FileThreadStore } from './store.js'; @@ -33,6 +33,26 @@ function deterministicOptions() { } describe('AppServer', () => { + it('binds workspace diff reads to the canonical thread cwd', async () => { + const workspaceDiff = vi.fn(async () => ({ + repository: true as const, + base: 'HEAD' as const, + files: [], + truncated: false, + })); + const server = new AppServer({ + executor: { execute: async () => ({}) }, + workspaceDiff, + }); + const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (thread.result as { id: string }).id; + await expect(server.handle(request(2, 'workspace/diff', { threadId }))).resolves.toEqual({ + id: 2, + result: expect.objectContaining({ repository: true }), + }); + expect(workspaceDiff).toHaveBeenCalledWith('/workspace'); + }); + it('does not let a tracing sink change protocol behavior', async () => { const server = new AppServer({ executor: { execute: async () => ({}) }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98bdcb1..df4fbd3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,7 @@ import { type ThreadSnapshot, type ThreadStore, type TurnSnapshot, + type WorkspaceDiffResult, } from '@deepcode/protocol'; export interface TurnExecutionItem { @@ -59,6 +60,7 @@ export interface AppServerOptions { onTrace?: (record: AppServerTraceRecord) => void; configDiagnostics?: (cwd: string) => Promise; diagnosticExport?: (cwd: string) => Promise; + workspaceDiff?: (cwd: string) => Promise; } /** Strictly metadata-only records; no prompt, tool payload, command, or error message. */ @@ -117,6 +119,7 @@ export class AppServer { onEvent: options.onEvent, configDiagnostics: options.configDiagnostics !== undefined, diagnosticExport: options.diagnosticExport !== undefined, + workspaceDiff: options.workspaceDiff !== undefined, }); } @@ -196,6 +199,13 @@ export class AppServer { throw new RequestValidationError('Diagnostic export is not available'); } return this.options.diagnosticExport(requiredString(request.params, 'cwd')); + case 'workspace/diff': { + if (!this.options.workspaceDiff) { + throw new RequestValidationError('Workspace diff is not available'); + } + const thread = await this.lifecycle.resumeThread(requiredId(request.params, 'threadId')); + return this.options.workspaceDiff(thread.cwd); + } case 'thread/start': return this.lifecycle.startThread(requiredString(request.params, 'cwd'), traceId); case 'thread/read': diff --git a/apps/server/src/structured-logger.ts b/apps/server/src/structured-logger.ts index debe896..5dab24a 100644 --- a/apps/server/src/structured-logger.ts +++ b/apps/server/src/structured-logger.ts @@ -34,6 +34,7 @@ const PROTOCOL_METHODS = new Set([ 'initialize', 'config/diagnostics', 'diagnostics/export', + 'workspace/diff', 'thread/start', 'thread/read', 'thread/resume', diff --git a/apps/server/src/workspace-diff.test.ts b/apps/server/src/workspace-diff.test.ts new file mode 100644 index 0000000..7a450cc --- /dev/null +++ b/apps/server/src/workspace-diff.test.ts @@ -0,0 +1,91 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { collectWorkspaceDiff } from './workspace-diff.js'; + +const exec = promisify(execFile); +let root: string | undefined; + +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function repository(): Promise { + root = await mkdtemp(join(tmpdir(), 'deepcode-workspace-diff-')); + await exec('git', ['init', '-q'], { cwd: root }); + await exec('git', ['config', 'user.email', 'deepcode@example.invalid'], { cwd: root }); + await exec('git', ['config', 'user.name', 'DeepCode Test'], { cwd: root }); + await writeFile(join(root, 'modify me.txt'), 'one\ntwo\nthree\n'); + await writeFile(join(root, 'delete.ts'), 'delete me\n'); + await writeFile(join(root, 'rename-old.ts'), 'rename me\n'); + await exec('git', ['add', '.'], { cwd: root }); + await exec('git', ['commit', '-qm', 'initial'], { cwd: root }); + return root; +} + +describe('collectWorkspaceDiff', () => { + it('returns structured tracked and untracked hunks without shell parsing', async () => { + const cwd = await repository(); + await writeFile(join(cwd, 'modify me.txt'), 'one\nchanged\nthree\n'); + await rm(join(cwd, 'delete.ts')); + await mkdir(join(cwd, 'new dir')); + await writeFile(join(cwd, 'new dir', 'new.ts'), 'export const value = 1;\n'); + await exec('git', ['mv', 'rename-old.ts', 'renamed.ts'], { cwd }); + + const diff = await collectWorkspaceDiff(cwd); + expect(diff).toMatchObject({ repository: true, base: 'HEAD', truncated: false }); + expect(diff.files.map((file) => [file.path, file.status])).toEqual([ + ['delete.ts', 'deleted'], + ['modify me.txt', 'modified'], + ['new dir/new.ts', 'added'], + ['renamed.ts', 'renamed'], + ]); + expect(diff.files.find((file) => file.path === 'renamed.ts')?.previousPath).toBe( + 'rename-old.ts', + ); + expect(diff.files.find((file) => file.path === 'modify me.txt')).toMatchObject({ + additions: 1, + deletions: 1, + binary: false, + hunks: [ + expect.objectContaining({ + lines: expect.arrayContaining([ + { kind: 'deletion', oldLine: 2, text: 'two' }, + { kind: 'addition', newLine: 2, text: 'changed' }, + ]), + }), + ], + }); + }); + + it('does not read untracked symlinks or binary contents', async () => { + const cwd = await repository(); + await writeFile(join(cwd, 'binary.bin'), Buffer.from([0, 1, 2, 3])); + await import('node:fs/promises').then(({ symlink }) => + symlink('/etc/passwd', join(cwd, 'outside-link')), + ); + + const diff = await collectWorkspaceDiff(cwd); + expect(diff.files.find((file) => file.path === 'binary.bin')).toMatchObject({ binary: true }); + expect(diff.files.find((file) => file.path === 'outside-link')).toMatchObject({ + binary: true, + hunks: [], + }); + }); + + it('returns an empty non-repository result outside git', async () => { + root = await mkdtemp(join(tmpdir(), 'deepcode-no-git-')); + await expect(collectWorkspaceDiff(root)).resolves.toEqual({ + repository: false, + base: null, + files: [], + truncated: false, + }); + }); +}); diff --git a/apps/server/src/workspace-diff.ts b/apps/server/src/workspace-diff.ts new file mode 100644 index 0000000..9b878c2 --- /dev/null +++ b/apps/server/src/workspace-diff.ts @@ -0,0 +1,208 @@ +import { execFile } from 'node:child_process'; +import { lstat, readFile } from 'node:fs/promises'; +import { isAbsolute, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import type { + WorkspaceDiffFile, + WorkspaceDiffHunk, + WorkspaceDiffResult, + WorkspaceFileStatus, +} from '@deepcode/protocol'; +import { gitSpawnEnv } from '@deepcode/core'; + +const execFileAsync = promisify(execFile); +const MAX_FILES = 100; +const MAX_FILE_BYTES = 128 * 1024; +const MAX_PATCH_BYTES = 256 * 1024; +const MAX_GIT_BUFFER = 32 * 1024 * 1024; + +export async function collectWorkspaceDiff(cwd: string): Promise { + const workspace = resolve(cwd); + const status = await git(workspace, [ + 'status', + '--porcelain=v1', + '-z', + '--untracked-files=all', + '--', + ]); + if (!status.ok) return { repository: false, base: null, files: [], truncated: false }; + const hasHead = (await git(workspace, ['rev-parse', '--verify', 'HEAD'])).ok; + const entries = parseStatus(status.stdout); + const selected = entries.slice(0, MAX_FILES); + let remainingBytes = MAX_PATCH_BYTES; + let truncated = entries.length > selected.length; + const files: WorkspaceDiffFile[] = []; + + for (const entry of selected) { + let patch = ''; + let binary = false; + let fileTruncated = false; + if (entry.untracked || !hasHead) { + const captured = await addedFilePatch(workspace, entry.path, remainingBytes); + patch = captured.patch; + binary = captured.binary; + fileTruncated = captured.truncated; + } else { + const result = await git(workspace, [ + '--literal-pathspecs', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--unified=3', + 'HEAD', + '--', + entry.path, + ]); + patch = result.ok ? result.stdout : ''; + binary = /(?:Binary files|GIT binary patch)/.test(patch); + if (Buffer.byteLength(patch) > remainingBytes) { + patch = truncateUtf8(patch, remainingBytes); + fileTruncated = true; + } + } + remainingBytes = Math.max(0, remainingBytes - Buffer.byteLength(patch)); + if (remainingBytes === 0) truncated = true; + const hunks = binary ? [] : parseHunks(patch); + files.push({ + path: entry.path, + previousPath: entry.previousPath, + status: entry.status, + additions: hunks.reduce( + (total, hunk) => total + hunk.lines.filter((line) => line.kind === 'addition').length, + 0, + ), + deletions: hunks.reduce( + (total, hunk) => total + hunk.lines.filter((line) => line.kind === 'deletion').length, + 0, + ), + binary, + truncated: fileTruncated, + hunks, + }); + if (remainingBytes === 0) break; + } + return { repository: true, base: hasHead ? 'HEAD' : 'empty', files, truncated }; +} + +interface StatusEntry { + path: string; + previousPath?: string; + status: WorkspaceFileStatus; + untracked: boolean; +} + +function parseStatus(raw: string): StatusEntry[] { + const fields = raw.split('\0'); + const entries: StatusEntry[] = []; + for (let index = 0; index < fields.length; index++) { + const field = fields[index]; + if (!field || field.length < 4) continue; + const code = field.slice(0, 2); + const path = field.slice(3); + let previousPath: string | undefined; + if (code.includes('R') || code.includes('C')) previousPath = fields[++index] || undefined; + entries.push({ + path, + previousPath, + status: fileStatus(code), + untracked: code === '??', + }); + } + return entries.sort((left, right) => left.path.localeCompare(right.path)); +} + +function fileStatus(code: string): WorkspaceFileStatus { + if (code === '??' || code.includes('A')) return 'added'; + if (code.includes('U') || code === 'AA' || code === 'DD') return 'conflicted'; + if (code.includes('R') || code.includes('C')) return 'renamed'; + if (code.includes('D')) return 'deleted'; + return 'modified'; +} + +async function addedFilePatch( + cwd: string, + path: string, + budget: number, +): Promise<{ patch: string; binary: boolean; truncated: boolean }> { + const absolute = resolve(cwd, path); + const relativePath = relative(cwd, absolute); + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + return { patch: '', binary: true, truncated: true }; + } + try { + const metadata = await lstat(absolute); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + return { patch: '', binary: true, truncated: false }; + } + const limit = Math.min(MAX_FILE_BYTES, budget); + const contents = await readFile(absolute); + if (contents.includes(0)) return { patch: '', binary: true, truncated: false }; + const truncated = contents.length > limit; + const text = contents.subarray(0, limit).toString('utf8'); + const lines = text.length === 0 ? [] : text.replace(/\n$/, '').split('\n'); + return { + patch: `@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}`).join('\n')}\n`, + binary: false, + truncated, + }; + } catch { + return { patch: '', binary: true, truncated: false }; + } +} + +function parseHunks(patch: string): WorkspaceDiffHunk[] { + const hunks: WorkspaceDiffHunk[] = []; + let current: WorkspaceDiffHunk | undefined; + let oldLine = 0; + let newLine = 0; + for (const rawLine of patch.split('\n')) { + const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(rawLine); + if (header) { + oldLine = Number(header[1]); + newLine = Number(header[3]); + current = { + oldStart: oldLine, + oldLines: Number(header[2] ?? 1), + newStart: newLine, + newLines: Number(header[4] ?? 1), + lines: [], + }; + hunks.push(current); + continue; + } + if (!current || rawLine === '\\ No newline at end of file') continue; + if (rawLine.startsWith('+')) { + current.lines.push({ kind: 'addition', newLine: newLine++, text: rawLine.slice(1) }); + } else if (rawLine.startsWith('-')) { + current.lines.push({ kind: 'deletion', oldLine: oldLine++, text: rawLine.slice(1) }); + } else if (rawLine.startsWith(' ')) { + current.lines.push({ + kind: 'context', + oldLine: oldLine++, + newLine: newLine++, + text: rawLine.slice(1), + }); + } + } + return hunks; +} + +async function git(cwd: string, args: string[]): Promise<{ ok: boolean; stdout: string }> { + try { + const result = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + timeout: 10_000, + maxBuffer: MAX_GIT_BUFFER, + env: { ...gitSpawnEnv(), GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat', LC_ALL: 'C' }, + }); + return { ok: true, stdout: result.stdout }; + } catch { + return { ok: false, stdout: '' }; + } +} + +function truncateUtf8(value: string, bytes: number): string { + return Buffer.from(value).subarray(0, Math.max(0, bytes)).toString('utf8'); +} diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 0f570b5..55bf946 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -58,7 +58,7 @@ trusted `settings.json` model and effort remain authoritative. ## Roadmap -- Real diff fetch via `vscode.git` API for `deepcode.review` +- Inline review comments and per-finding apply/revert controls - File panel showing live edits as the agent works - Inline webview approval cards (host-native warning actions work today) - Custom commands via skills (mirror CLI's `/skills` dir) diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 9a18ba6..df9b1e8 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -7,6 +7,7 @@ import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; import { EditorProtocolRuntime } from './protocol-runtime.js'; import { formatConfigDiagnostics } from './diagnostics.js'; import { explicitConfigValue } from './settings.js'; +import { formatWorkspaceDiffForReview } from './workspace-diff.js'; type V = typeof import('vscode'); @@ -51,12 +52,24 @@ export async function activate(context: vscode.ExtensionContext): Promise void window.showInformationMessage('DeepCode: open a folder first.'); return; } - await runInOutput( - 'Review the current uncommitted diff. Cite file:line for each finding. ' + - 'Categorize as BUG / LATENT / SUGGESTION.', - vscodeMod, - runtime, - ); + try { + const diff = await runtime.diff(); + if (!diff.repository || diff.files.length === 0) { + void window.showInformationMessage('DeepCode: no uncommitted changes to review.'); + return; + } + await runInOutput( + 'Review the canonical workspace diff below. Cite file:line for each finding. ' + + 'Categorize as BUG / LATENT / SUGGESTION.\n\n' + + formatWorkspaceDiffForReview(diff), + vscodeMod, + runtime, + ); + } catch (error) { + void window.showErrorMessage( + `DeepCode review failed: ${(error as Error).message ?? String(error)}`, + ); + } }), commands.registerCommand('deepcode.showDiagnostics', async () => { const out = window.createOutputChannel('DeepCode Diagnostics'); diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index 628b923..e4de3e2 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -6,6 +6,7 @@ import type { ProtocolRequest, ThreadSnapshot, TurnSnapshot, + WorkspaceDiffResult, } from '@deepcode/protocol'; import { describe, expect, it, vi } from 'vitest'; @@ -41,6 +42,7 @@ class FakeClient { interactiveRequests: true, configDiagnostics: true, diagnosticExport: true, + workspaceDiff: true, }, }; } @@ -68,6 +70,7 @@ class FakeClient { } if (method === 'turn/interrupt') return { interrupted: true } as T; if (method === 'config/diagnostics') return diagnostics as T; + if (method === 'workspace/diff') return workspaceDiff as T; return { accepted: true } as T; } @@ -87,7 +90,26 @@ const diagnostics: ConfigDiagnosticsResult = { issues: [], }; +const workspaceDiff: WorkspaceDiffResult = { + repository: true, + base: 'HEAD', + files: [], + truncated: false, +}; + describe('EditorProtocolRuntime', () => { + it('reads the canonical workspace diff through the active thread', async () => { + const client = new FakeClient(); + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + + await expect(runtime.diff()).resolves.toEqual(workspaceDiff); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/start', + 'workspace/diff', + ]); + expect(client.requests.at(-1)?.params).toEqual({ threadId: 'thread-1' }); + }); + it('reads configuration diagnostics from the app-server for the editor workspace', 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 920140e..bf908e9 100644 --- a/apps/vscode/src/protocol-runtime.ts +++ b/apps/vscode/src/protocol-runtime.ts @@ -5,6 +5,7 @@ import type { ProtocolMethod, ThreadSnapshot, TurnSnapshot, + WorkspaceDiffResult, } from '@deepcode/protocol'; export interface EditorProtocolClient { @@ -81,6 +82,15 @@ export class EditorProtocolRuntime { return this.client.request('config/diagnostics', { cwd: this.cwd() }); } + async diff(): Promise { + const initialized = await this.client.connect(); + if (!initialized.capabilities.workspaceDiff) { + throw new Error('The app-server does not support workspace diff'); + } + const thread = await this.ensureThread(); + return this.client.request('workspace/diff', { threadId: thread.id }); + } + async interrupt(turnId: string): Promise { const threadId = this.turnThreads.get(turnId); if (!threadId) return false; diff --git a/apps/vscode/src/workspace-diff.test.ts b/apps/vscode/src/workspace-diff.test.ts new file mode 100644 index 0000000..9da3409 --- /dev/null +++ b/apps/vscode/src/workspace-diff.test.ts @@ -0,0 +1,39 @@ +import type { WorkspaceDiffResult } from '@deepcode/protocol'; +import { describe, expect, it } from 'vitest'; + +import { formatWorkspaceDiffForReview } from './workspace-diff.js'; + +describe('formatWorkspaceDiffForReview', () => { + it('renders the canonical DTO without invoking git', () => { + const diff: WorkspaceDiffResult = { + repository: true, + base: 'HEAD', + truncated: false, + files: [ + { + path: 'src/a.ts', + status: 'modified', + additions: 1, + deletions: 1, + binary: false, + truncated: false, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { kind: 'deletion', oldLine: 1, text: 'old' }, + { kind: 'addition', newLine: 1, text: 'new' }, + ], + }, + ], + }, + ], + }; + expect(formatWorkspaceDiffForReview(diff)).toContain( + 'diff -- modified "src/a.ts"\n@@ -1,1 +1,1 @@\n-old\n+new', + ); + }); +}); diff --git a/apps/vscode/src/workspace-diff.ts b/apps/vscode/src/workspace-diff.ts new file mode 100644 index 0000000..d4494ac --- /dev/null +++ b/apps/vscode/src/workspace-diff.ts @@ -0,0 +1,25 @@ +import type { WorkspaceDiffResult } from '@deepcode/protocol'; + +export function formatWorkspaceDiffForReview(diff: WorkspaceDiffResult): string { + const lines: string[] = []; + for (const file of diff.files) { + const path = file.previousPath + ? `${JSON.stringify(file.previousPath)} -> ${JSON.stringify(file.path)}` + : JSON.stringify(file.path); + lines.push(`diff -- ${file.status} ${path}`); + if (file.binary) { + lines.push('[binary content omitted]'); + continue; + } + for (const hunk of file.hunks) { + lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + for (const line of hunk.lines) { + const marker = line.kind === 'addition' ? '+' : line.kind === 'deletion' ? '-' : ' '; + lines.push(`${marker}${line.text}`); + } + } + if (file.truncated) lines.push('[file diff truncated]'); + } + if (diff.truncated) lines.push('[workspace diff truncated]'); + return lines.join('\n'); +} diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 77046cd..2994aee 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -330,8 +330,11 @@ model tool call 关联 ID、事件、状态码与耗时,不序列化协议 payload。`diagnostics/export` 与 CLI 共用脱敏器, 路径哈希化、配置值/issue message 省略,导出前再次白名单清洗;删除 `logs/`/`diagnostics/` 即可回滚,不影响 canonical thread。 +- `workspace/diff` 已把 Git 工作区变化收敛成 app-server 拥有的有界 file/hunk/line DTO,并以 + canonical `threadId` 绑定 cwd;Git 不经 shell,未跟踪 symlink/binary 不读取内容。VS Code + review、Desktop protocol agent 与 LSP command 共享该能力,不再各自解析 diff。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 -- diff review、可定位反馈。 +- 逐行可定位反馈、单项 apply/revert 与 review all。 - 删除完成迁移的旧 IPC/facade;更新所有用户文档。 - release candidate、迁移演练、性能预算和回滚说明。 diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 84c8950..7b9549d 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -17,3 +17,6 @@ opaque correlation value, not as authorization or a persistence key. `diagnostics/export` is capability-negotiated. It returns only the local bundle path, generation time, and record count; the app-server owns path hashing and payload redaction. + +`workspace/diff` is also capability-negotiated and requires a canonical `threadId`. It returns +bounded file, hunk, and line objects rather than a client-specific raw patch. diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts index 2358eb6..aa4be60 100644 --- a/packages/protocol/src/codec.test.ts +++ b/packages/protocol/src/codec.test.ts @@ -38,6 +38,7 @@ describe('protocol codec', () => { 'user-input/respond', 'config/diagnostics', 'diagnostics/export', + 'workspace/diff', ] as const)('accepts the interactive response method %s', (method) => { expect(decodeProtocolRequest(JSON.stringify({ id: 2, method, params: {} }))).toEqual({ id: 2, diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index fe48001..7f73b66 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -9,6 +9,7 @@ const protocolMethods = new Set([ 'initialize', 'config/diagnostics', 'diagnostics/export', + 'workspace/diff', 'thread/start', 'thread/read', 'thread/resume', diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts index fa54a7a..b50dbdf 100644 --- a/packages/protocol/src/runtime.test.ts +++ b/packages/protocol/src/runtime.test.ts @@ -37,6 +37,7 @@ describe('ProtocolRuntime', () => { interactiveRequests: true, configDiagnostics: false, diagnosticExport: false, + workspaceDiff: false, }, }); }); diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index a122b32..3ac0c76 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -43,6 +43,7 @@ export interface ProtocolRuntimeOptions { onEvent?: (event: ProtocolEvent) => void; configDiagnostics?: boolean; diagnosticExport?: boolean; + workspaceDiff?: boolean; } export class ProtocolInvariantError extends Error { @@ -80,6 +81,7 @@ export class ProtocolRuntime { interactiveRequests: true, configDiagnostics: this.options.configDiagnostics ?? false, diagnosticExport: this.options.diagnosticExport ?? false, + workspaceDiff: this.options.workspaceDiff ?? false, }, }; } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index a5955c9..0ba8273 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -133,6 +133,7 @@ export interface InitializeResult { interactiveRequests: true; configDiagnostics: boolean; diagnosticExport: boolean; + workspaceDiff: boolean; }; } @@ -164,10 +165,47 @@ export interface DiagnosticExportResult { recordCount: number; } +export type WorkspaceFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'conflicted'; +export type WorkspaceDiffLineKind = 'context' | 'addition' | 'deletion'; + +export interface WorkspaceDiffLine { + kind: WorkspaceDiffLineKind; + oldLine?: number; + newLine?: number; + text: string; +} + +export interface WorkspaceDiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: WorkspaceDiffLine[]; +} + +export interface WorkspaceDiffFile { + path: string; + previousPath?: string; + status: WorkspaceFileStatus; + additions: number; + deletions: number; + binary: boolean; + truncated: boolean; + hunks: WorkspaceDiffHunk[]; +} + +export interface WorkspaceDiffResult { + repository: boolean; + base: 'HEAD' | 'empty' | null; + files: WorkspaceDiffFile[]; + truncated: boolean; +} + export type ProtocolMethod = | 'initialize' | 'config/diagnostics' | 'diagnostics/export' + | 'workspace/diff' | 'thread/start' | 'thread/read' | 'thread/resume' From abb1de087877ed51d505a8df62115d4394564b79 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 17:06:56 +0800 Subject: [PATCH 28/33] fix: use collision-resistant ipc ids --- packages/core/src/ipc/protocol.test.ts | 8 ++++---- packages/core/src/ipc/protocol.ts | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ipc/protocol.test.ts b/packages/core/src/ipc/protocol.test.ts index 0cef4a1..2cea696 100644 --- a/packages/core/src/ipc/protocol.test.ts +++ b/packages/core/src/ipc/protocol.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'; import { newQuestionId, newTurnId } from './protocol.js'; describe('newTurnId', () => { - it('returns turn--', () => { + it('returns turn--', () => { const id = newTurnId(); - expect(id).toMatch(/^turn-[0-9a-z]+-[0-9a-z]+$/); + expect(id).toMatch(/^turn-[0-9a-z]+-[0-9a-f]{24}$/); }); it('produces unique ids across rapid calls', () => { const set = new Set(Array.from({ length: 50 }, newTurnId)); @@ -13,9 +13,9 @@ describe('newTurnId', () => { }); describe('newQuestionId', () => { - it('returns q--', () => { + it('returns q--', () => { const id = newQuestionId(); - expect(id).toMatch(/^q-[0-9a-z]+-[0-9a-z]+$/); + expect(id).toMatch(/^q-[0-9a-z]+-[0-9a-f]{24}$/); }); it('produces unique ids', () => { const set = new Set(Array.from({ length: 50 }, newQuestionId)); diff --git a/packages/core/src/ipc/protocol.ts b/packages/core/src/ipc/protocol.ts index a7630f7..ac5340b 100644 --- a/packages/core/src/ipc/protocol.ts +++ b/packages/core/src/ipc/protocol.ts @@ -11,6 +11,7 @@ // Channel naming convention: `:` for request/response invokes // and `:event` for streamed events. +import { randomBytes } from 'node:crypto'; import type { AgentEvent, Mode, StoredMessage } from '../types.js'; // ────────────────────────────────────────────────────────────────────────── @@ -145,15 +146,15 @@ export type IpcResponse = IpcRequestMap[C]['res']; /** * Generate a fresh turn ID — used by the main process when starting a turn. - * Format: `turn--`. + * Format: `turn--`. */ export function newTurnId(): string { - return `turn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + return `turn-${Date.now().toString(36)}-${randomBytes(12).toString('hex')}`; } /** * Generate a fresh question ID for an AskUserQuestion prompt. */ export function newQuestionId(): string { - return `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + return `q-${Date.now().toString(36)}-${randomBytes(12).toString('hex')}`; } From 360a214f426812fd580936b9a0dc23dc24a3dffc Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 17:04:50 +0800 Subject: [PATCH 29/33] feat: persist structured review findings --- apps/desktop/src/lib/protocol-agent.test.ts | 54 ++++++++++ apps/desktop/src/lib/protocol-agent.ts | 36 +++++-- apps/lsp/src/handler.test.ts | 28 +++++ apps/lsp/src/handler.ts | 10 ++ apps/server/README.md | 5 + apps/server/src/runtime-composition.ts | 3 +- apps/server/src/runtime-executor.test.ts | 41 ++++++- apps/server/src/runtime-executor.ts | 40 ++++++- apps/vscode/README.md | 15 +-- apps/vscode/package.json | 4 + apps/vscode/src/extension.ts | 39 ++++++- apps/vscode/src/protocol-runtime.test.ts | 24 +++++ apps/vscode/src/protocol-runtime.ts | 25 +++-- docs/CODEX_ALIGNMENT_PLAN.md | 5 +- packages/core/src/agent.ts | 9 +- packages/core/src/index.ts | 2 + packages/core/src/modes/index.test.ts | 4 +- packages/core/src/modes/index.ts | 1 + packages/core/src/runtime/policy.ts | 1 + packages/core/src/tools/index.ts | 1 + packages/core/src/tools/registry.ts | 2 + .../core/src/tools/review-finding.test.ts | 38 +++++++ packages/core/src/tools/review-finding.ts | 102 ++++++++++++++++++ packages/protocol/README.md | 3 + packages/protocol/src/index.ts | 1 + packages/protocol/src/review.test.ts | 36 +++++++ packages/protocol/src/review.ts | 59 ++++++++++ packages/protocol/src/types.ts | 12 +++ 28 files changed, 569 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/tools/review-finding.test.ts create mode 100644 packages/core/src/tools/review-finding.ts create mode 100644 packages/protocol/src/review.test.ts create mode 100644 packages/protocol/src/review.ts diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index e7311b3..86abf5e 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -3,6 +3,7 @@ import type { InitializeResult, ProtocolEvent, ProtocolMethod, + ReviewFindingPayload, ThreadSnapshot, TurnSnapshot, WorkspaceDiffResult, @@ -67,6 +68,16 @@ const workspaceDiff: WorkspaceDiffResult = { truncated: false, }; +const finding: ReviewFindingPayload = { + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, +}; + const thread: ThreadSnapshot = { id: 'thread-1', cwd: '/workspace', @@ -96,6 +107,19 @@ describe('DesktopProtocolAgent', () => { }); }); + it('applies a finding as a normal agent turn', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await agent.resume(thread.id); + await agent.applyFinding(finding); + expect(transport.requests.at(-1)).toEqual({ + method: 'turn/start', + params: expect.objectContaining({ + input: expect.objectContaining({ text: expect.stringContaining('normal editing tools') }), + }), + }); + }); + it('reads value-free diagnostics from the shared app-server', async () => { const transport = new FakeTransport(); const agent = new DesktopProtocolAgent(transport, () => undefined); @@ -201,6 +225,36 @@ describe('DesktopProtocolAgent', () => { }); }); + it('projects durable review findings for line-addressable UI rendering', 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: 'review' }); + transport.handler?.({ + type: 'item.completed', + threadId: thread.id, + turnId: turn.id, + item: { + id: 'item-1', + type: 'review_finding', + completedAt: '2026-08-01T00:00:02.000Z', + payload: { + findingId: 'finding-1', + title: 'Null crash', + body: 'This branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + }, + }, + }); + expect(events).toContainEqual( + expect.objectContaining({ type: 'review_finding', path: 'src/a.ts', startLine: 4 }), + ); + }); + it('drops late events after clearing an active thread', async () => { const transport = new FakeTransport(); const events: unknown[] = []; diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts index 1cc7fcf..4b3d4ab 100644 --- a/apps/desktop/src/lib/protocol-agent.ts +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -1,11 +1,13 @@ -import type { - ConfigDiagnosticsResult, - InitializeResult, - ProtocolEvent, - ProtocolMethod, - ThreadSnapshot, - TurnSnapshot, - WorkspaceDiffResult, +import { + reviewApplyPrompt, + type ConfigDiagnosticsResult, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ReviewFindingPayload, + type ThreadSnapshot, + type TurnSnapshot, + type WorkspaceDiffResult, } from '@deepcode/protocol'; import { setActiveSessionId } from './mac-session.js'; @@ -102,6 +104,10 @@ export class DesktopProtocolAgent { return this.transport.request('workspace/diff', { threadId: this.threadId }); } + applyFinding(finding: ReviewFindingPayload) { + return this.start({ userMessage: reviewApplyPrompt(finding) }); + } + clear(): void { void this.interruptActiveTurns(); this.threadId = null; @@ -226,6 +232,16 @@ export class DesktopProtocolAgent { multiSelect: event.multiSelect, }); break; + case 'item.completed': + if (event.item.type === 'review_finding') { + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'review_finding', + ...event.item.payload, + }); + } + break; case 'turn.completed': this.finish(event.turn.id, 'end_turn'); break; @@ -320,6 +336,10 @@ export function getWorkspaceDiff() { return defaultAgent.diff(); } +export function applyReviewFinding(finding: ReviewFindingPayload) { + return defaultAgent.applyFinding(finding); +} + export function abortProtocolTurn(turnId: string) { return defaultAgent.abort(turnId); } diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index fff11ea..0944a45 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -158,6 +158,7 @@ describe('handleMessage — initialize', () => { 'deepcode.respondUserInput', 'deepcode.configDiagnostics', 'deepcode.workspaceDiff', + 'deepcode.applyReviewFinding', ]), ); }); @@ -194,6 +195,33 @@ describe('handleMessage — protocol commands', () => { ]); }); + it('applies a finding through the normal runAgent turn path', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + await execute( + 21, + 'deepcode.applyReviewFinding', + { + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + }, + (message) => out.push(message), + ); + expect(out.find((message) => message.id === 21)?.result).toEqual({ + threadId: 'thread-1', + turnId: 'turn-1', + }); + expect(client.requests.at(-1)?.params.input).toEqual( + expect.objectContaining({ text: expect.stringContaining('normal editing tools') }), + ); + }); + 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 51ce3c4..281f56c 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -9,6 +9,8 @@ import { type InitializeResult, type ProtocolEvent, type ProtocolMethod, + reviewApplyPrompt, + type ReviewFindingPayload, type ThreadSnapshot, type TurnSnapshot, type WorkspaceDiffResult, @@ -68,6 +70,7 @@ const COMMANDS = [ 'deepcode.listSkills', 'deepcode.configDiagnostics', 'deepcode.workspaceDiff', + 'deepcode.applyReviewFinding', ]; export async function handleMessage(msg: LspMessage, send: SendFn): Promise { @@ -174,6 +177,13 @@ async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): return handleConfigDiagnostics(); case 'deepcode.workspaceDiff': return handleWorkspaceDiff(); + case 'deepcode.applyReviewFinding': + return handleRunAgent( + { + prompt: reviewApplyPrompt((params.arguments?.[0] ?? {}) as ReviewFindingPayload), + }, + send, + ); default: throw new Error(`Unknown command: ${params.command}`); } diff --git a/apps/server/README.md b/apps/server/README.md index 91cffea..929b41c 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -47,3 +47,8 @@ rollback for this optional observability layer. The server invokes Git without a shell and returns a bounded file/hunk/line DTO for tracked and untracked changes. Untracked symlinks and binary contents are never read into the response. Desktop, VS Code, and LSP consume this same capability; clients do not parse Git output independently. + +The read-only `SubmitReviewFinding` tool turns model findings into durable `review_finding` items +with a workspace-relative path, tight line range, priority, and optional exact replacement. Applying +one is deliberately another canonical turn, not a direct filesystem endpoint: clients use the shared +prompt builder and the existing Edit/Write permission, approval, hook, sandbox, and snapshot path. diff --git a/apps/server/src/runtime-composition.ts b/apps/server/src/runtime-composition.ts index 86753ee..e94941a 100644 --- a/apps/server/src/runtime-composition.ts +++ b/apps/server/src/runtime-composition.ts @@ -37,7 +37,8 @@ import { 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.'; + 'explain what you changed and why. For code review, call SubmitReviewFinding once for each ' + + 'actionable issue, using a precise workspace-relative path and line range.'; export interface RuntimeCompositionDiagnostic { source: 'mcp' | 'plugin'; diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts index 957e806..a246824 100644 --- a/apps/server/src/runtime-executor.test.ts +++ b/apps/server/src/runtime-executor.test.ts @@ -6,6 +6,7 @@ import { RuntimeHost, SessionManager, ToolRegistry, + type AgentEvent, type Provider, type ProviderResult, type ProviderRunOpts, @@ -13,7 +14,11 @@ import { import type { ThreadSnapshot, TurnSnapshot } from '@deepcode/protocol'; import { describe, expect, it, vi } from 'vitest'; -import { RuntimeHostExecutor, historyFromThread } from './runtime-executor.js'; +import { + RuntimeHostExecutor, + historyFromThread, + reviewFindingsFromEvents, +} from './runtime-executor.js'; function protocolCallbacks() { return { @@ -101,6 +106,40 @@ class ToolProvider implements Provider { } describe('RuntimeHostExecutor', () => { + it('projects validated review tool results into durable finding items', () => { + const events: AgentEvent[] = [ + { + type: 'tool_use', + id: 'finding-1', + name: 'SubmitReviewFinding', + input: {}, + }, + { + type: 'tool_result', + id: 'finding-1', + result: { + content: 'recorded', + data: { + finding: { + title: 'Null crash', + body: 'This branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + }, + }, + }, + }, + ]; + expect(reviewFindingsFromEvents(events)).toEqual([ + { + type: 'review_finding', + payload: expect.objectContaining({ findingId: 'finding-1', path: 'src/a.ts' }), + }, + ]); + }); + it('reconstructs history and returns only messages created by the new turn', async () => { const provider = new StreamingProvider(); const host = new RuntimeHost({ diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts index 7570fd1..0dacd5d 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -2,6 +2,7 @@ import { type AgentEvent, type Effort, type Mode, + type ReviewFinding, type RuntimeHost, type SessionManager, type StoredMessage, @@ -148,7 +149,11 @@ export class RuntimeHostExecutor implements TurnExecutor { }); const newMessages = result.history.slice(baselineLength); - const items = [...interactionItems, ...completedItemsFromMessages(newMessages, text)]; + const items = [ + ...interactionItems, + ...reviewFindingsFromEvents(events), + ...completedItemsFromMessages(newMessages, text), + ]; if (result.stopReason === 'error') { const error = [...events].reverse().find((event) => event.type === 'error'); if (error?.type === 'error') { @@ -165,6 +170,39 @@ export class RuntimeHostExecutor implements TurnExecutor { } } +export function reviewFindingsFromEvents(events: AgentEvent[]): TurnExecutionItem[] { + const calls = new Map>(); + const items: TurnExecutionItem[] = []; + for (const event of events) { + if (event.type === 'tool_use' && event.name === 'SubmitReviewFinding') { + calls.set(event.id, event.input); + } else if (event.type === 'tool_result' && calls.has(event.id) && !event.result.isError) { + const finding = event.result.data?.finding; + if (isReviewFinding(finding)) { + items.push({ + type: 'review_finding', + payload: { findingId: event.id, ...finding }, + }); + } + calls.delete(event.id); + } + } + return items; +} + +function isReviewFinding(value: unknown): value is ReviewFinding { + if (!value || typeof value !== 'object') return false; + const finding = value as Partial; + return ( + typeof finding.title === 'string' && + typeof finding.body === 'string' && + typeof finding.path === 'string' && + Number.isInteger(finding.startLine) && + Number.isInteger(finding.endLine) && + Number.isInteger(finding.priority) + ); +} + const MODES = new Set([ 'default', 'acceptEdits', diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 55bf946..e170764 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -38,12 +38,13 @@ 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 | -| `deepcode.showDiagnostics` | (palette) | Show value-free config sources, trust gates, and issues | +| 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.applyReviewFinding` | (API/context) | Apply one structured finding through a normal agent turn | +| `deepcode.showDiagnostics` | (palette) | Show value-free config sources, trust gates, and issues | ## Settings @@ -58,7 +59,7 @@ trusted `settings.json` model and effort remain authoritative. ## Roadmap -- Inline review comments and per-finding apply/revert controls +- Inline review comments, context actions, and per-finding revert controls - File panel showing live edits as the agent works - Inline webview approval cards (host-native warning actions work today) - Custom commands via skills (mirror CLI's `/skills` dir) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 186e904..da659e5 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -40,6 +40,10 @@ "command": "deepcode.review", "title": "DeepCode: Review current diff" }, + { + "command": "deepcode.applyReviewFinding", + "title": "DeepCode: Apply Review Finding" + }, { "command": "deepcode.showDiagnostics", "title": "DeepCode: Show Configuration Diagnostics" diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index df9b1e8..049b72a 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -1,7 +1,7 @@ // 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 { ProtocolClient, type ProtocolEvent, type ReviewFindingPayload } from '@deepcode/protocol'; import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; import { EditorProtocolRuntime } from './protocol-runtime.js'; @@ -71,6 +71,16 @@ export async function activate(context: vscode.ExtensionContext): Promise ); } }), + commands.registerCommand( + 'deepcode.applyReviewFinding', + async (finding: ReviewFindingPayload | undefined) => { + if (!finding?.findingId) { + void window.showErrorMessage('DeepCode: a review finding is required.'); + return; + } + await runFindingInOutput(finding, vscodeMod, runtime); + }, + ), commands.registerCommand('deepcode.showDiagnostics', async () => { const out = window.createOutputChannel('DeepCode Diagnostics'); out.show(true); @@ -85,6 +95,24 @@ export async function activate(context: vscode.ExtensionContext): Promise ); } +async function runFindingInOutput( + finding: ReviewFindingPayload, + vscodeMod: V, + runtime: EditorProtocolRuntime, +): Promise { + const out = vscodeMod.window.createOutputChannel('DeepCode'); + out.show(true); + out.appendLine(`Applying review finding: ${finding.title}`); + try { + await runtime.applyFinding(finding, (event) => { + projectOutputEvent(event, out); + void respondToInteraction(event, vscodeMod, runtime); + }); + } catch (error) { + out.appendLine(`\n✕ ${(error as Error).message ?? String(error)}`); + } +} + export async function deactivate(): Promise { const runtime = activeRuntime; activeRuntime = undefined; @@ -150,6 +178,15 @@ function projectOutputEvent(event: ProtocolEvent, out: vscode.OutputChannel): vo case 'turn.failed': out.appendLine(`\n✕ ${turnError(event.turn) ?? 'turn failed'}\n`); break; + case 'item.completed': + if (event.item.type === 'review_finding') { + const finding = event.item.payload; + out.appendLine( + `\n[P${String(finding.priority)}] ${String(finding.title)} — ${String(finding.path)}:${String(finding.startLine)}`, + ); + out.appendLine(` ${String(finding.body)}`); + } + break; } } diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index e4de3e2..424f73d 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -4,6 +4,7 @@ import type { ProtocolEvent, ProtocolMethod, ProtocolRequest, + ReviewFindingPayload, ThreadSnapshot, TurnSnapshot, WorkspaceDiffResult, @@ -97,6 +98,16 @@ const workspaceDiff: WorkspaceDiffResult = { truncated: false, }; +const finding: ReviewFindingPayload = { + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, +}; + describe('EditorProtocolRuntime', () => { it('reads the canonical workspace diff through the active thread', async () => { const client = new FakeClient(); @@ -110,6 +121,19 @@ describe('EditorProtocolRuntime', () => { expect(client.requests.at(-1)?.params).toEqual({ threadId: 'thread-1' }); }); + it('applies a finding through a normal permission-gated turn', async () => { + const client = new FakeClient(); + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + await runtime.applyFinding(finding, () => undefined); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/start', + 'turn/start', + ]); + expect(client.requests.at(-1)?.params.input).toEqual( + expect.objectContaining({ text: expect.stringContaining('normal editing tools') }), + ); + }); + it('reads configuration diagnostics from the app-server for the editor workspace', 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 bf908e9..e0e4290 100644 --- a/apps/vscode/src/protocol-runtime.ts +++ b/apps/vscode/src/protocol-runtime.ts @@ -1,11 +1,13 @@ -import type { - ConfigDiagnosticsResult, - InitializeResult, - ProtocolEvent, - ProtocolMethod, - ThreadSnapshot, - TurnSnapshot, - WorkspaceDiffResult, +import { + reviewApplyPrompt, + type ConfigDiagnosticsResult, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ReviewFindingPayload, + type ThreadSnapshot, + type TurnSnapshot, + type WorkspaceDiffResult, } from '@deepcode/protocol'; export interface EditorProtocolClient { @@ -91,6 +93,13 @@ export class EditorProtocolRuntime { return this.client.request('workspace/diff', { threadId: thread.id }); } + applyFinding( + finding: ReviewFindingPayload, + onEvent: EventHandler, + ): Promise<{ threadId: string; turnId: string }> { + return this.start({ text: reviewApplyPrompt(finding) }, onEvent); + } + 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 2994aee..6c37cbc 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -333,8 +333,11 @@ model tool call - `workspace/diff` 已把 Git 工作区变化收敛成 app-server 拥有的有界 file/hunk/line DTO,并以 canonical `threadId` 绑定 cwd;Git 不经 shell,未跟踪 symlink/binary 不读取内容。VS Code review、Desktop protocol agent 与 LSP command 共享该能力,不再各自解析 diff。 +- 只读 `SubmitReviewFinding` tool 已把模型反馈持久化为含 path/line/priority/replacement 的 + `review_finding` item;单项 Apply 由 shared prompt 派生成新的 canonical turn,而不是新增 + 直写接口,因此继续经过 Edit/Write permission、approval、hook、sandbox 与 snapshot。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 -- 逐行可定位反馈、单项 apply/revert 与 review all。 +- 客户端内联评论/上下文 action、单项 revert 与 review all。 - 删除完成迁移的旧 IPC/facade;更新所有用户文档。 - release candidate、迁移演练、性能预算和回滚说明。 diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 6b85f51..ca731dc 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -178,7 +178,14 @@ async function waitForApproval( * TodoWrite/AskUserQuestion/ExitPlanMode) runs sequentially to preserve snapshot * ordering, mutation order, and one-at-a-time interactive prompts. */ -const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch']); +const READ_ONLY_TOOLS = new Set([ + 'Read', + 'Grep', + 'Glob', + 'WebFetch', + 'WebSearch', + 'SubmitReviewFinding', +]); /** * Runs the agent loop until the model produces an end_turn (no tool calls), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c9f78b7..2149716 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export { WebFetchTool, WebSearchTool, AskUserQuestionTool, + SubmitReviewFindingTool, ExitPlanModeTool, makeToolSearchTool, installToolSearch, @@ -51,6 +52,7 @@ export { type TodoItem, type TodoStatus, type SearchHit, + type ReviewFinding, } from './tools/index.js'; // Sessions diff --git a/packages/core/src/modes/index.test.ts b/packages/core/src/modes/index.test.ts index df29399..d51953f 100644 --- a/packages/core/src/modes/index.test.ts +++ b/packages/core/src/modes/index.test.ts @@ -18,8 +18,8 @@ describe('evaluateMode', () => { expect(evaluateMode(mode, req('Edit', 'allow'))).toBe('plan-blocked'); expect(evaluateMode(mode, req('Bash', 'allow'))).toBe('plan-blocked'); }); - it('allows read-only tools (Read, Grep, Glob, WebFetch, WebSearch)', () => { - for (const t of ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch']) { + it('allows read-only tools, including structured review presentation', () => { + for (const t of ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'SubmitReviewFinding']) { expect(evaluateMode(mode, req(t, 'no-match'))).toBe('allow'); } }); diff --git a/packages/core/src/modes/index.ts b/packages/core/src/modes/index.ts index c8cd5bc..f74f16d 100644 --- a/packages/core/src/modes/index.ts +++ b/packages/core/src/modes/index.ts @@ -31,6 +31,7 @@ const PLAN_READONLY_TOOLS = new Set([ 'WebFetch', 'WebSearch', 'AskUserQuestion', + 'SubmitReviewFinding', 'ExitPlanMode', 'ToolSearch', ]); diff --git a/packages/core/src/runtime/policy.ts b/packages/core/src/runtime/policy.ts index 7ce3403..fb60197 100644 --- a/packages/core/src/runtime/policy.ts +++ b/packages/core/src/runtime/policy.ts @@ -14,6 +14,7 @@ export const SAFE_READONLY_TOOLS = Object.freeze([ 'WebFetch', 'WebSearch', 'AskUserQuestion', + 'SubmitReviewFinding', 'ExitPlanMode', 'ToolSearch', ] as const); diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index b6b4d4e..2ca0d0b 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -14,6 +14,7 @@ export { WebFetchTool } from './web-fetch.js'; export { WebSearchTool, parseDuckDuckGoHtml } from './web-search.js'; export type { SearchHit } from './web-search.js'; export { AskUserQuestionTool } from './ask-user.js'; +export { SubmitReviewFindingTool, type ReviewFinding } from './review-finding.js'; export { ExitPlanModeTool } from './exit-plan.js'; export { CronCreateTool, CronListTool, CronDeleteTool } from './cron-tools.js'; export { diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index e44be4f..58104b4 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -13,6 +13,7 @@ import { GlobTool } from './glob.js'; import { GrepTool } from './grep.js'; import { NotebookEditTool } from './notebook.js'; import { ReadTool } from './read.js'; +import { SubmitReviewFindingTool } from './review-finding.js'; import { TaskTool } from './task.js'; import { TodoWriteTool } from './todo.js'; import { WebFetchTool } from './web-fetch.js'; @@ -40,6 +41,7 @@ export const BUILTIN_TOOLS: ToolHandler[] = [ WebFetchTool, WebSearchTool, AskUserQuestionTool, + SubmitReviewFindingTool, EnterPlanModeTool, ExitPlanModeTool, EnterWorktreeTool, diff --git a/packages/core/src/tools/review-finding.test.ts b/packages/core/src/tools/review-finding.test.ts new file mode 100644 index 0000000..426d582 --- /dev/null +++ b/packages/core/src/tools/review-finding.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { SubmitReviewFindingTool } from './review-finding.js'; + +describe('SubmitReviewFindingTool', () => { + it('returns a structured line-addressable finding', async () => { + const result = await SubmitReviewFindingTool.execute( + { + title: 'Handle the null branch', + body: 'The value can be null and crashes this path.', + path: 'src/a.ts', + startLine: 10, + endLine: 11, + priority: 1, + replacement: 'if (value === null) return;', + }, + { cwd: '/workspace' }, + ); + expect(result).toEqual( + expect.objectContaining({ + data: { + finding: expect.objectContaining({ path: 'src/a.ts', startLine: 10, priority: 1 }), + }, + }), + ); + }); + + it.each(['../secret', '/etc/passwd', 'C:\\secret.txt', 'src//a.ts'])( + 'rejects unsafe path %s', + async (path) => { + const result = await SubmitReviewFindingTool.execute( + { title: 'x', body: 'y', path, startLine: 1, endLine: 1, priority: 2 }, + { cwd: '/workspace' }, + ); + expect(result.isError).toBe(true); + }, + ); +}); diff --git a/packages/core/src/tools/review-finding.ts b/packages/core/src/tools/review-finding.ts new file mode 100644 index 0000000..239fc4a --- /dev/null +++ b/packages/core/src/tools/review-finding.ts @@ -0,0 +1,102 @@ +import { isAbsolute } from 'node:path'; + +import type { ToolHandler, ToolResult } from '../types.js'; + +export interface ReviewFinding { + title: string; + body: string; + path: string; + startLine: number; + endLine: number; + priority: 0 | 1 | 2 | 3; + replacement?: string; +} + +/** Read-only presentation tool: records a structured, line-addressable review finding. */ +export const SubmitReviewFindingTool: ToolHandler = { + name: 'SubmitReviewFinding', + definition: { + name: 'SubmitReviewFinding', + description: + 'Submit one actionable code-review finding. Use once per distinct defect, with a precise workspace-relative file and tight line range. Do not use for praise or summaries. Include replacement only when an exact edit is safe.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Short actionable title.' }, + body: { type: 'string', description: 'One paragraph explaining impact and trigger.' }, + path: { type: 'string', description: 'Workspace-relative file path.' }, + startLine: { type: 'integer', minimum: 1 }, + endLine: { type: 'integer', minimum: 1 }, + priority: { type: 'integer', enum: [0, 1, 2, 3] }, + replacement: { + type: 'string', + description: 'Optional exact replacement text for the cited line range.', + }, + }, + required: ['title', 'body', 'path', 'startLine', 'endLine', 'priority'], + }, + }, + async execute(input: Record): Promise { + const finding = parseFinding(input); + if (!finding) return { content: 'Error: invalid review finding.', isError: true }; + return { + content: `Recorded ${finding.path}:${finding.startLine} — ${finding.title}`, + data: { finding }, + }; + }, +}; + +function parseFinding(input: Record): ReviewFinding | null { + const { title, body, path, startLine, endLine, priority, replacement } = input; + if ( + typeof title !== 'string' || + title.length === 0 || + title.length > 160 || + hasControlCharacter(title) || + typeof body !== 'string' || + body.length === 0 || + body.length > 4000 || + typeof path !== 'string' || + !safeRelativePath(path) || + !Number.isInteger(startLine) || + !Number.isInteger(endLine) || + (startLine as number) < 1 || + (endLine as number) < (startLine as number) || + (endLine as number) - (startLine as number) > 200 || + !Number.isInteger(priority) || + ![0, 1, 2, 3].includes(priority as number) || + (replacement !== undefined && + (typeof replacement !== 'string' || Buffer.byteLength(replacement) > 32 * 1024)) + ) { + return null; + } + return { + title, + body, + path, + startLine: startLine as number, + endLine: endLine as number, + priority: priority as 0 | 1 | 2 | 3, + ...(typeof replacement === 'string' ? { replacement } : {}), + }; +} + +function safeRelativePath(path: string): boolean { + if ( + path.length === 0 || + path.length > 500 || + hasControlCharacter(path) || + isAbsolute(path) || + /^[a-zA-Z]:[\\/]/.test(path) + ) { + return false; + } + return !path.split(/[\\/]/).some((part) => part === '..' || part === ''); +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }); +} diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 7b9549d..32287c4 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -20,3 +20,6 @@ time, and record count; the app-server owns path hashing and payload redaction. `workspace/diff` is also capability-negotiated and requires a canonical `threadId`. It returns bounded file, hunk, and line objects rather than a client-specific raw patch. + +Actionable review output is persisted as `review_finding` completed items. `reviewApplyPrompt` +converts a selected finding into a verification-first follow-up turn; it never writes directly. diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index e4ea5b0..445286a 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -2,3 +2,4 @@ export * from './types.js'; export * from './runtime.js'; export * from './codec.js'; export * from './client.js'; +export * from './review.js'; diff --git a/packages/protocol/src/review.test.ts b/packages/protocol/src/review.test.ts new file mode 100644 index 0000000..c7a165d --- /dev/null +++ b/packages/protocol/src/review.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { reviewApplyPrompt } from './review.js'; + +describe('reviewApplyPrompt', () => { + it('creates a verification-first turn prompt with the exact finding identity', () => { + const prompt = reviewApplyPrompt({ + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + replacement: 'if (value === null) return;', + }); + expect(prompt).toContain('Apply review finding finding-1'); + expect(prompt).toContain('Re-read the file and verify'); + expect(prompt).toContain('normal editing tools'); + }); + + it('rejects malformed external command payloads', () => { + expect(() => reviewApplyPrompt({} as never)).toThrow('valid review finding'); + expect(() => + reviewApplyPrompt({ + findingId: 'finding-1', + title: 'unsafe', + body: 'unsafe path', + path: '../outside', + startLine: 1, + endLine: 1, + priority: 1, + }), + ).toThrow('valid review finding'); + }); +}); diff --git a/packages/protocol/src/review.ts b/packages/protocol/src/review.ts new file mode 100644 index 0000000..ab41b7a --- /dev/null +++ b/packages/protocol/src/review.ts @@ -0,0 +1,59 @@ +import type { ReviewFindingPayload } from './types.js'; + +/** + * Applying a finding is intentionally a new agent turn, never a direct write + * endpoint, so the normal permission/hook/sandbox/snapshot pipeline remains in force. + */ +export function reviewApplyPrompt(finding: ReviewFindingPayload): string { + if ( + !finding || + typeof finding.findingId !== 'string' || + !finding.findingId || + typeof finding.title !== 'string' || + finding.title.length === 0 || + finding.title.length > 160 || + typeof finding.body !== 'string' || + finding.body.length === 0 || + finding.body.length > 4000 || + typeof finding.path !== 'string' || + !safeRelativePath(finding.path) || + !Number.isInteger(finding.startLine) || + !Number.isInteger(finding.endLine) || + finding.startLine < 1 || + finding.endLine < finding.startLine || + ![0, 1, 2, 3].includes(finding.priority) || + (finding.replacement !== undefined && + (typeof finding.replacement !== 'string' || finding.replacement.length > 32 * 1024)) + ) { + throw new Error('A valid review finding is required'); + } + const replacement = finding.replacement ? `\nSuggested replacement:\n${finding.replacement}` : ''; + return ( + `Apply review finding ${finding.findingId}: ${finding.title}\n` + + `Location: ${JSON.stringify(finding.path)}:${finding.startLine}-${finding.endLine}\n` + + `${finding.body}${replacement}\n\n` + + 'Re-read the file and verify the finding is still current. Make only the minimal safe change, ' + + 'using the normal editing tools. If the code has changed or the finding is invalid, explain and do not edit.' + ); +} + +function safeRelativePath(path: string): boolean { + if ( + path.length === 0 || + path.length > 500 || + path.startsWith('/') || + path.startsWith('\\') || + /^[a-zA-Z]:[\\/]/.test(path) || + hasControlCharacter(path) + ) { + return false; + } + return !path.split(/[\\/]/).some((part) => part === '..' || part === ''); +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }); +} diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 0ba8273..a1d1ea9 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -8,6 +8,7 @@ export type CompletedItemType = | 'tool_result' | 'approval' | 'ask_user' + | 'review_finding' | 'error'; export interface CompletedItem { @@ -201,6 +202,17 @@ export interface WorkspaceDiffResult { truncated: boolean; } +export interface ReviewFindingPayload { + findingId: string; + title: string; + body: string; + path: string; + startLine: number; + endLine: number; + priority: 0 | 1 | 2 | 3; + replacement?: string; +} + export type ProtocolMethod = | 'initialize' | 'config/diagnostics' From a9dcdd33b5929a89f19afd4d9737596123b130e8 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 17:14:40 +0800 Subject: [PATCH 30/33] feat: add canonical review action lifecycle --- apps/desktop/src/lib/protocol-agent.test.ts | 22 +++- apps/desktop/src/lib/protocol-agent.ts | 26 ++++- apps/desktop/src/lib/protocol-client.test.ts | 1 + apps/desktop/src/preview-app.tsx | 1 + apps/lsp/README.md | 26 +++-- apps/lsp/src/handler.test.ts | 26 ++++- apps/lsp/src/handler.ts | 36 ++++++- apps/server/README.md | 8 +- apps/server/src/client.test.ts | 1 + apps/server/src/server.test.ts | 88 +++++++++++++++- apps/server/src/server.ts | 70 ++++++++++++- apps/vscode/README.md | 17 ++-- apps/vscode/package.json | 4 + apps/vscode/src/extension.ts | 40 ++++++-- apps/vscode/src/protocol-runtime.test.ts | 12 ++- apps/vscode/src/protocol-runtime.ts | 26 ++++- docs/CODEX_ALIGNMENT_PLAN.md | 9 +- docs/design/app-server-v1.md | 8 ++ docs/design/runtime-protocol-v1.md | 10 +- packages/protocol/README.md | 7 +- packages/protocol/src/client.test.ts | 1 + packages/protocol/src/codec.test.ts | 1 + packages/protocol/src/codec.ts | 1 + packages/protocol/src/review.test.ts | 30 +++++- packages/protocol/src/review.ts | 102 ++++++++++++++----- packages/protocol/src/runtime.test.ts | 1 + packages/protocol/src/runtime.ts | 2 + packages/protocol/src/types.ts | 9 ++ 28 files changed, 498 insertions(+), 87 deletions(-) diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index 86abf5e..e635a0c 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -26,6 +26,7 @@ class FakeTransport implements ProtocolTransport { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + reviewActions: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, @@ -45,6 +46,7 @@ class FakeTransport implements ProtocolTransport { 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 === 'review/apply') return turn as T; if (method === 'turn/interrupt') return { interrupted: true } as T; if (method === 'config/diagnostics') return diagnostics as T; if (method === 'workspace/diff') return workspaceDiff as T; @@ -113,10 +115,8 @@ describe('DesktopProtocolAgent', () => { await agent.resume(thread.id); await agent.applyFinding(finding); expect(transport.requests.at(-1)).toEqual({ - method: 'turn/start', - params: expect.objectContaining({ - input: expect.objectContaining({ text: expect.stringContaining('normal editing tools') }), - }), + method: 'review/apply', + params: { threadId: thread.id, findingIds: ['finding-1'] }, }); }); @@ -253,6 +253,20 @@ describe('DesktopProtocolAgent', () => { expect(events).toContainEqual( expect.objectContaining({ type: 'review_finding', path: 'src/a.ts', startLine: 4 }), ); + transport.handler?.({ + type: 'item.completed', + threadId: thread.id, + turnId: turn.id, + item: { + id: 'item-2', + type: 'review_action', + completedAt: '2026-08-01T00:00:03.000Z', + payload: { actionId: turn.id, kind: 'apply', findingIds: ['finding-1'] }, + }, + }); + expect(events).toContainEqual( + expect.objectContaining({ type: 'review_action', findingIds: ['finding-1'] }), + ); }); it('drops late events after clearing an active thread', async () => { diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts index 4b3d4ab..8723ae7 100644 --- a/apps/desktop/src/lib/protocol-agent.ts +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -1,5 +1,4 @@ import { - reviewApplyPrompt, type ConfigDiagnosticsResult, type InitializeResult, type ProtocolEvent, @@ -105,7 +104,23 @@ export class DesktopProtocolAgent { } applyFinding(finding: ReviewFindingPayload) { - return this.start({ userMessage: reviewApplyPrompt(finding) }); + return this.applyFindings([finding]); + } + + async applyFindings(findings: ReviewFindingPayload[]) { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const threadId = this.threadId; + if (!threadId) throw new Error('No active workspace thread'); + const turn = await this.transport.request('review/apply', { + threadId, + findingIds: findings.map((finding) => finding.findingId), + }); + this.activeTurns.set(turn.id, threadId); + setTimeout(() => this.flushTurn(turn.id), 0); + return { turnId: turn.id, threadId }; } clear(): void { @@ -240,6 +255,13 @@ export class DesktopProtocolAgent { type: 'review_finding', ...event.item.payload, }); + } else if (event.item.type === 'review_action') { + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'review_action', + ...event.item.payload, + }); } break; case 'turn.completed': diff --git a/apps/desktop/src/lib/protocol-client.test.ts b/apps/desktop/src/lib/protocol-client.test.ts index 5ee0b2e..ba2d7d5 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, + reviewActions: true, configDiagnostics: true, }, } diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index 47a8904..9721538 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, + reviewActions: true, configDiagnostics: true, }, }); diff --git a/apps/lsp/README.md b/apps/lsp/README.md index ca1872e..5d5dcd4 100644 --- a/apps/lsp/README.md +++ b/apps/lsp/README.md @@ -6,16 +6,19 @@ 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[] }` | -| `deepcode.configDiagnostics` | none | value-free config sources, trust gates, and issues | +| 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 | +| `deepcode.workspaceDiff` | none | canonical structured workspace diff | +| `deepcode.applyReviewFinding` | `{ findingId }` | `{ threadId, turnId }` | +| `deepcode.applyReviewFindings` | `{ findingIds }` | `{ threadId, turnId }` | Lifecycle, structured tool, usage, approval, and user-input events are sent unchanged as `deepcode/protocolEvent` notifications: @@ -117,5 +120,6 @@ In `Preferences → Package Settings → LSP → Settings`: ## Current scope The bridge covers thread start/read/resume, turn start/interrupt, structured events, approvals, -AskUserQuestion, and configuration diagnostics. Multi-client attachment and shared-daemon +AskUserQuestion, configuration diagnostics, canonical workspace diff, and single/batch review +actions. 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 0944a45..1c6c7a3 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -21,6 +21,7 @@ const capabilities: InitializeResult = { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + reviewActions: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, @@ -81,7 +82,8 @@ class FakeClient { case 'thread/read': case 'thread/resume': return this.thread as T; - case 'turn/start': { + case 'turn/start': + case 'review/apply': { // Deliberately precedes the response to exercise the LSP fast-turn queue. this.emit({ type: 'turn.started', threadId: this.thread.id, turn: this.turn }); queueMicrotask(() => { @@ -159,6 +161,7 @@ describe('handleMessage — initialize', () => { 'deepcode.configDiagnostics', 'deepcode.workspaceDiff', 'deepcode.applyReviewFinding', + 'deepcode.applyReviewFindings', ]), ); }); @@ -217,9 +220,26 @@ describe('handleMessage — protocol commands', () => { threadId: 'thread-1', turnId: 'turn-1', }); - expect(client.requests.at(-1)?.params.input).toEqual( - expect.objectContaining({ text: expect.stringContaining('normal editing tools') }), + expect(client.requests.at(-1)).toMatchObject({ + method: 'review/apply', + params: { threadId: 'thread-1', findingIds: ['finding-1'] }, + }); + }); + + it('applies a bounded finding batch by canonical id', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + await execute( + 22, + 'deepcode.applyReviewFindings', + { findingIds: ['finding-1', 'finding-2'] }, + (message) => out.push(message), ); + expect(client.requests.at(-1)).toMatchObject({ + method: 'review/apply', + params: { threadId: 'thread-1', findingIds: ['finding-1', 'finding-2'] }, + }); }); it('starts a canonical thread and emits native protocol events in order', async () => { diff --git a/apps/lsp/src/handler.ts b/apps/lsp/src/handler.ts index 281f56c..4760ce7 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -9,7 +9,6 @@ import { type InitializeResult, type ProtocolEvent, type ProtocolMethod, - reviewApplyPrompt, type ReviewFindingPayload, type ThreadSnapshot, type TurnSnapshot, @@ -71,6 +70,7 @@ const COMMANDS = [ 'deepcode.configDiagnostics', 'deepcode.workspaceDiff', 'deepcode.applyReviewFinding', + 'deepcode.applyReviewFindings', ]; export async function handleMessage(msg: LspMessage, send: SendFn): Promise { @@ -178,10 +178,13 @@ async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): case 'deepcode.workspaceDiff': return handleWorkspaceDiff(); case 'deepcode.applyReviewFinding': - return handleRunAgent( - { - prompt: reviewApplyPrompt((params.arguments?.[0] ?? {}) as ReviewFindingPayload), - }, + return handleReviewApply( + [((params.arguments?.[0] ?? {}) as ReviewFindingPayload).findingId], + send, + ); + case 'deepcode.applyReviewFindings': + return handleReviewApply( + ((params.arguments?.[0] ?? {}) as { findingIds?: string[] }).findingIds ?? [], send, ); default: @@ -217,6 +220,29 @@ async function handleRunAgent( return { threadId: thread.id, turnId: turn.id }; } +async function handleReviewApply( + findingIds: Array, + send: SendFn, +): Promise<{ threadId: string; turnId: string }> { + if (findingIds.length === 0 || findingIds.some((findingId) => !findingId)) { + throw new Error('At least one findingId is required'); + } + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const thread = await ensureThread(client); + const turn = await client.request('review/apply', { + threadId: thread.id, + findingIds: findingIds as string[], + }); + state.activeTurns.set(turn.id, thread.id); + state.turnSinks.set(turn.id, send); + flushEvents(turn.id); + return { threadId: thread.id, turnId: turn.id }; +} + 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); diff --git a/apps/server/README.md b/apps/server/README.md index 929b41c..69119eb 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -49,6 +49,8 @@ untracked changes. Untracked symlinks and binary contents are never read into th VS Code, and LSP consume this same capability; clients do not parse Git output independently. The read-only `SubmitReviewFinding` tool turns model findings into durable `review_finding` items -with a workspace-relative path, tight line range, priority, and optional exact replacement. Applying -one is deliberately another canonical turn, not a direct filesystem endpoint: clients use the shared -prompt builder and the existing Edit/Write permission, approval, hook, sandbox, and snapshot path. +with a workspace-relative path, tight line range, priority, and optional exact replacement. +`review/apply` accepts one bounded list of finding ids, resolves the original payloads from the +canonical thread, builds the verification-first prompt in the host, and records a `review_action` +item tied to the new turn. It is not a filesystem endpoint: every edit still uses the existing +Edit/Write permission, approval, hook, sandbox, and snapshot path. diff --git a/apps/server/src/client.test.ts b/apps/server/src/client.test.ts index c4e4507..c5f6363 100644 --- a/apps/server/src/client.test.ts +++ b/apps/server/src/client.test.ts @@ -14,6 +14,7 @@ lines.on('line', (line) => { ? { protocolVersion: 1, capabilities: { threadResume: true, turnInterrupt: true, completedItemPersistence: true, transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + reviewActions: true, configDiagnostics: true } } : { echoed: request.method }; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 679d453..3b8eff2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,7 +2,7 @@ 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 type { ProtocolEvent, ProtocolRequest, ThreadSnapshot } from '@deepcode/protocol'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AppServer, type TurnExecutor } from './server.js'; @@ -33,6 +33,92 @@ function deterministicOptions() { } describe('AppServer', () => { + it('resolves review actions from canonical findings and persists their turn association', async () => { + const executor: TurnExecutor = { + execute: vi + .fn() + .mockResolvedValueOnce({ + items: [ + { + type: 'review_finding', + payload: { + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + }, + }, + ], + }) + .mockResolvedValueOnce({}), + }; + const server = new AppServer({ executor, ...deterministicOptions() }); + const started = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (started.result as { id: string }).id; + await server.handle(request(2, 'turn/start', { threadId, input: { text: 'review' } })); + await server.waitForIdle(); + + const applied = await server.handle( + request(3, 'review/apply', { threadId, findingIds: ['finding-1'] }), + ); + const turnId = (applied.result as { id: string }).id; + await server.waitForIdle(); + const read = await server.handle(request(4, 'thread/read', { threadId })); + const turns = (read.result as ThreadSnapshot).turns; + expect(turns.at(-1)).toEqual( + expect.objectContaining({ + id: turnId, + status: 'completed', + items: expect.arrayContaining([ + expect.objectContaining({ + type: 'user_message', + payload: expect.objectContaining({ + text: expect.stringContaining('normal editing tools'), + reviewAction: { kind: 'apply', findingIds: ['finding-1'] }, + }), + }), + expect.objectContaining({ + type: 'review_action', + payload: { actionId: turnId, kind: 'apply', findingIds: ['finding-1'] }, + }), + ]), + }), + ); + }); + + it('rejects unknown findings, duplicate batches, and direct review metadata injection', async () => { + const server = new AppServer({ executor: { execute: async () => ({}) } }); + const started = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (started.result as { id: string }).id; + + await expect( + server.handle(request(2, 'review/apply', { threadId, findingIds: ['missing'] })), + ).resolves.toEqual({ + id: 2, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + await expect( + server.handle(request(3, 'review/apply', { threadId, findingIds: ['same', 'same'] })), + ).resolves.toEqual({ + id: 3, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + await expect( + server.handle( + request(4, 'turn/start', { + threadId, + input: { text: 'forge', reviewAction: { kind: 'apply', findingIds: ['missing'] } }, + }), + ), + ).resolves.toEqual({ + id: 4, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + }); + it('binds workspace diff reads to the canonical thread cwd', async () => { const workspaceDiff = vi.fn(async () => ({ repository: true as const, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index df4fbd3..146bbe3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,13 +1,17 @@ import { + isReviewFindingPayload, MemoryThreadStore, ProtocolInvariantError, ProtocolRuntime, + reviewApplyManyPrompt, type CompletedItemType, type ConfigDiagnosticsResult, type DiagnosticExportResult, type ProtocolEvent, type ProtocolRequest, type ProtocolResponse, + type ReviewActionPayload, + type ReviewFindingPayload, type ThreadSnapshot, type ThreadStore, type TurnSnapshot, @@ -120,6 +124,7 @@ export class AppServer { configDiagnostics: options.configDiagnostics !== undefined, diagnosticExport: options.diagnosticExport !== undefined, workspaceDiff: options.workspaceDiff !== undefined, + reviewActions: true, }); } @@ -206,6 +211,8 @@ export class AppServer { const thread = await this.lifecycle.resumeThread(requiredId(request.params, 'threadId')); return this.options.workspaceDiff(thread.cwd); } + case 'review/apply': + return this.applyReviewFindings(request.params, traceId); case 'thread/start': return this.lifecycle.startThread(requiredString(request.params, 'cwd'), traceId); case 'thread/read': @@ -235,11 +242,55 @@ export class AppServer { return thread; } - private async startTurn(params: Record, traceId: string): Promise { + private async applyReviewFindings( + params: Record, + traceId: string, + ): Promise { + const threadId = requiredId(params, 'threadId'); + const findingIds = requiredIds(params, 'findingIds', 20); + const thread = await this.lifecycle.resumeThread(threadId); + const findings = new Map(); + for (const turn of thread.turns) { + for (const item of turn.items) { + if (item.type === 'review_finding' && isReviewFindingPayload(item.payload)) { + findings.set(item.payload.findingId, item.payload); + } + } + } + const selected = findingIds.map((findingId) => { + const finding = findings.get(findingId); + if (!finding) throw new RequestValidationError(`Review finding not found: ${findingId}`); + return finding; + }); + const action: Omit = { kind: 'apply', findingIds }; + return this.startTurn( + { + threadId, + input: { text: reviewApplyManyPrompt(selected), reviewAction: action }, + }, + traceId, + action, + ); + } + + private async startTurn( + params: Record, + traceId: string, + reviewAction?: Omit, + ): Promise { const threadId = requiredId(params, 'threadId'); const input = requiredRecord(params, 'input'); + if (!reviewAction && Object.hasOwn(input, 'reviewAction')) { + throw new RequestValidationError('reviewAction is reserved for app-server review methods'); + } const thread = await this.lifecycle.resumeThread(threadId); const turn = await this.lifecycle.startTurn(threadId, input, traceId); + if (reviewAction) { + await this.lifecycle.appendCompletedItem(threadId, turn.id, 'review_action', { + actionId: turn.id, + ...reviewAction, + }); + } const controller = new AbortController(); const task = this.executeTurn(thread, turn, input, controller); this.activeTurns.set(turn.id, { threadId, controller, task }); @@ -551,3 +602,20 @@ function requiredRecord(params: Record, key: string): Record; } + +function requiredIds(params: Record, key: string, maximum: number): string[] { + const value = params[key]; + if (!Array.isArray(value) || value.length === 0 || value.length > maximum) { + throw new RequestValidationError(`${key} must contain between 1 and ${maximum} ids`); + } + const ids = value.map((entry) => { + if (typeof entry !== 'string' || !/^[a-zA-Z0-9._-]{1,200}$/.test(entry)) { + throw new RequestValidationError(`${key} contains an invalid id`); + } + return entry; + }); + if (new Set(ids).size !== ids.length) { + throw new RequestValidationError(`${key} must not contain duplicate ids`); + } + return ids; +} diff --git a/apps/vscode/README.md b/apps/vscode/README.md index e170764..7d0939c 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 -- Four commands, an activity-bar chat view, model/effort settings, and a default +- Six 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,13 +38,14 @@ 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 | -| `deepcode.applyReviewFinding` | (API/context) | Apply one structured finding through a normal agent turn | -| `deepcode.showDiagnostics` | (palette) | Show value-free config sources, trust gates, and issues | +| 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.applyReviewFinding` | (API/context) | Apply one canonical finding through a normal agent turn | +| `deepcode.applyAllReviewFindings` | (palette) | Apply the latest review batch through one canonical turn | +| `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 da659e5..60be057 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -44,6 +44,10 @@ "command": "deepcode.applyReviewFinding", "title": "DeepCode: Apply Review Finding" }, + { + "command": "deepcode.applyAllReviewFindings", + "title": "DeepCode: Apply All Review Findings" + }, { "command": "deepcode.showDiagnostics", "title": "DeepCode: Show Configuration Diagnostics" diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 049b72a..b9b586a 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -1,7 +1,12 @@ // VS Code extension entry — thin UI over the shared app-server protocol. import type * as vscode from 'vscode'; -import { ProtocolClient, type ProtocolEvent, type ReviewFindingPayload } from '@deepcode/protocol'; +import { + isReviewFindingPayload, + ProtocolClient, + type ProtocolEvent, + type ReviewFindingPayload, +} from '@deepcode/protocol'; import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; import { EditorProtocolRuntime } from './protocol-runtime.js'; @@ -12,6 +17,7 @@ import { formatWorkspaceDiffForReview } from './workspace-diff.js'; type V = typeof import('vscode'); let activeRuntime: EditorProtocolRuntime | undefined; +const latestReviewFindings = new Map(); export async function activate(context: vscode.ExtensionContext): Promise { const vscodeMod = await loadVscode(); @@ -53,6 +59,7 @@ export async function activate(context: vscode.ExtensionContext): Promise return; } try { + latestReviewFindings.clear(); const diff = await runtime.diff(); if (!diff.repository || diff.files.length === 0) { void window.showInformationMessage('DeepCode: no uncommitted changes to review.'); @@ -78,9 +85,17 @@ export async function activate(context: vscode.ExtensionContext): Promise void window.showErrorMessage('DeepCode: a review finding is required.'); return; } - await runFindingInOutput(finding, vscodeMod, runtime); + await runFindingsInOutput([finding], vscodeMod, runtime); }, ), + commands.registerCommand('deepcode.applyAllReviewFindings', async () => { + const findings = [...latestReviewFindings.values()]; + if (findings.length === 0) { + void window.showInformationMessage('DeepCode: no review findings to apply.'); + return; + } + await runFindingsInOutput(findings, vscodeMod, runtime); + }), commands.registerCommand('deepcode.showDiagnostics', async () => { const out = window.createOutputChannel('DeepCode Diagnostics'); out.show(true); @@ -95,16 +110,20 @@ export async function activate(context: vscode.ExtensionContext): Promise ); } -async function runFindingInOutput( - finding: ReviewFindingPayload, +async function runFindingsInOutput( + findings: ReviewFindingPayload[], vscodeMod: V, runtime: EditorProtocolRuntime, ): Promise { const out = vscodeMod.window.createOutputChannel('DeepCode'); out.show(true); - out.appendLine(`Applying review finding: ${finding.title}`); + out.appendLine( + findings.length === 1 + ? `Applying review finding: ${findings[0]!.title}` + : `Applying ${findings.length} review findings`, + ); try { - await runtime.applyFinding(finding, (event) => { + await runtime.applyFindings(findings, (event) => { projectOutputEvent(event, out); void respondToInteraction(event, vscodeMod, runtime); }); @@ -181,10 +200,19 @@ function projectOutputEvent(event: ProtocolEvent, out: vscode.OutputChannel): vo case 'item.completed': if (event.item.type === 'review_finding') { const finding = event.item.payload; + if (isReviewFindingPayload(finding)) { + latestReviewFindings.set(finding.findingId, finding); + } out.appendLine( `\n[P${String(finding.priority)}] ${String(finding.title)} — ${String(finding.path)}:${String(finding.startLine)}`, ); out.appendLine(` ${String(finding.body)}`); + } else if (event.item.type === 'review_action') { + out.appendLine( + `\n[review action] ${String(event.item.payload.kind)} ${String( + (event.item.payload.findingIds as unknown[] | undefined)?.length ?? 0, + )} finding(s)`, + ); } break; } diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index 424f73d..25974bb 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -41,6 +41,7 @@ class FakeClient { transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + reviewActions: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, @@ -58,7 +59,7 @@ class FakeClient { if (method === 'thread/start' || method === 'thread/read' || method === 'thread/resume') { return this.thread as T; } - if (method === 'turn/start') { + if (method === 'turn/start' || method === 'review/apply') { this.emit({ type: 'turn.started', threadId: this.thread.id, turn: this.turn }); this.emit({ type: 'item.delta', @@ -127,11 +128,12 @@ describe('EditorProtocolRuntime', () => { await runtime.applyFinding(finding, () => undefined); expect(client.requests.map((request) => request.method)).toEqual([ 'thread/start', - 'turn/start', + 'review/apply', ]); - expect(client.requests.at(-1)?.params.input).toEqual( - expect.objectContaining({ text: expect.stringContaining('normal editing tools') }), - ); + expect(client.requests.at(-1)?.params).toEqual({ + threadId: 'thread-1', + findingIds: ['finding-1'], + }); }); it('reads configuration diagnostics from the app-server for the editor workspace', async () => { diff --git a/apps/vscode/src/protocol-runtime.ts b/apps/vscode/src/protocol-runtime.ts index e0e4290..4ad68ae 100644 --- a/apps/vscode/src/protocol-runtime.ts +++ b/apps/vscode/src/protocol-runtime.ts @@ -1,5 +1,4 @@ import { - reviewApplyPrompt, type ConfigDiagnosticsResult, type InitializeResult, type ProtocolEvent, @@ -97,7 +96,24 @@ export class EditorProtocolRuntime { finding: ReviewFindingPayload, onEvent: EventHandler, ): Promise<{ threadId: string; turnId: string }> { - return this.start({ text: reviewApplyPrompt(finding) }, onEvent); + return this.applyFindings([finding], onEvent); + } + + async applyFindings( + findings: ReviewFindingPayload[], + onEvent: EventHandler, + ): Promise<{ threadId: string; turnId: string }> { + const initialized = await this.client.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const thread = await this.ensureThread(); + const turn = await this.client.request('review/apply', { + threadId: thread.id, + findingIds: findings.map((finding) => finding.findingId), + }); + this.trackTurn(turn, thread.id, onEvent); + return { threadId: thread.id, turnId: turn.id }; } async interrupt(turnId: string): Promise { @@ -156,6 +172,12 @@ export class EditorProtocolRuntime { return this.client.request(method, { threadId, turnId, ...params }); } + private trackTurn(turn: TurnSnapshot, threadId: string, onEvent: EventHandler): void { + this.turnThreads.set(turn.id, threadId); + this.handlers.set(turn.id, onEvent); + this.flush(turn.id); + } + private route(event: ProtocolEvent): void { const turnId = turnIdFrom(event); if (!turnId) return; diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 6c37cbc..9041b41 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -334,10 +334,13 @@ model tool call canonical `threadId` 绑定 cwd;Git 不经 shell,未跟踪 symlink/binary 不读取内容。VS Code review、Desktop protocol agent 与 LSP command 共享该能力,不再各自解析 diff。 - 只读 `SubmitReviewFinding` tool 已把模型反馈持久化为含 path/line/priority/replacement 的 - `review_finding` item;单项 Apply 由 shared prompt 派生成新的 canonical turn,而不是新增 - 直写接口,因此继续经过 Edit/Write permission、approval、hook、sandbox 与 snapshot。 + `review_finding` item;`review/apply` 只接受 canonical thread 中已有 finding id,由 app-server + 解析原始 payload、生成单项或批量 prompt,并把 finding/action/turn 关联持久化为 + `review_action`。Apply 不暴露直写接口,因此继续经过 Edit/Write permission、approval、hook、 + sandbox 与 snapshot,客户端不能篡改 path/replacement 或伪造 finding。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 -- 客户端内联评论/上下文 action、单项 revert 与 review all。 +- 客户端内联评论/上下文 action 与冲突安全的单项 revert;VS Code review all 已由同一 + canonical action path 提供。 - 删除完成迁移的旧 IPC/facade;更新所有用户文档。 - release candidate、迁移演练、性能预算和回滚说明。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index b2bf94d..1b404a9 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -44,6 +44,9 @@ by expecting partial deltas to replay. | `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 | +| `diagnostics/export` | workspace cwd | redacted local diagnostic bundle metadata | +| `workspace/diff` | `threadId` | bounded structured workspace diff | +| `review/apply` | `threadId`, `findingIds` | permission-gated review action turn | `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 @@ -53,6 +56,11 @@ If a process crashes after persisting an in-progress turn, the next `thread/resu orphaned turn interrupted. Version 1 does not attempt to resurrect an unknown provider request or tool process after a crash. +`review/apply` resolves every id from durable `review_finding` items already stored in the target +thread, then generates the bounded verification prompt inside the app-server. Its `review_action` +item records the selected ids and action turn. Direct `turn/start` requests cannot inject this +reserved metadata. + ## Storage and security The Node-specific `CanonicalThreadStore` writes one mode-0600 lifecycle snapshot per thread under diff --git a/docs/design/runtime-protocol-v1.md b/docs/design/runtime-protocol-v1.md index 4fdb32d..6cc47c0 100644 --- a/docs/design/runtime-protocol-v1.md +++ b/docs/design/runtime-protocol-v1.md @@ -54,7 +54,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, interactive requests, and the optional availability of -value-free configuration diagnostics. +value-free configuration diagnostics, diagnostic export, canonical workspace diff, and review +actions. 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 @@ -62,6 +63,7 @@ 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. +The in-memory store and codec remain reference implementations used by contract tests. The +production app-server now owns persistent thread storage, `RuntimeHost` execution, interactive +requests, canonical workspace diff, structured review actions, and redacted tracing. Shared-daemon +multi-client attachment remains outside protocol v1. diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 32287c4..6b803ca 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -21,5 +21,8 @@ time, and record count; the app-server owns path hashing and payload redaction. `workspace/diff` is also capability-negotiated and requires a canonical `threadId`. It returns bounded file, hunk, and line objects rather than a client-specific raw patch. -Actionable review output is persisted as `review_finding` completed items. `reviewApplyPrompt` -converts a selected finding into a verification-first follow-up turn; it never writes directly. +Actionable review output is persisted as `review_finding` completed items. `review/apply` accepts +only finding ids already present in the canonical thread, resolves their original payloads in the +app-server, and starts one permission-gated turn for a selected finding or bounded batch. The +`review_action` completed item correlates that action with its turn; clients never send a writable +replacement payload or write directly. diff --git a/packages/protocol/src/client.test.ts b/packages/protocol/src/client.test.ts index 06ecda6..ebe0657 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, + reviewActions: true, configDiagnostics: true, }, } diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts index aa4be60..bc7b4d9 100644 --- a/packages/protocol/src/codec.test.ts +++ b/packages/protocol/src/codec.test.ts @@ -39,6 +39,7 @@ describe('protocol codec', () => { 'config/diagnostics', 'diagnostics/export', 'workspace/diff', + 'review/apply', ] as const)('accepts the interactive response method %s', (method) => { expect(decodeProtocolRequest(JSON.stringify({ id: 2, method, params: {} }))).toEqual({ id: 2, diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index 7f73b66..22038fe 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -10,6 +10,7 @@ const protocolMethods = new Set([ 'config/diagnostics', 'diagnostics/export', 'workspace/diff', + 'review/apply', 'thread/start', 'thread/read', 'thread/resume', diff --git a/packages/protocol/src/review.test.ts b/packages/protocol/src/review.test.ts index c7a165d..53c1857 100644 --- a/packages/protocol/src/review.test.ts +++ b/packages/protocol/src/review.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { reviewApplyPrompt } from './review.js'; +import { reviewApplyManyPrompt, reviewApplyPrompt } from './review.js'; describe('reviewApplyPrompt', () => { it('creates a verification-first turn prompt with the exact finding identity', () => { @@ -33,4 +33,32 @@ describe('reviewApplyPrompt', () => { }), ).toThrow('valid review finding'); }); + + it('builds bounded batch prompts and rejects duplicate finding ids', () => { + const finding = { + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1 as const, + }; + const prompt = reviewApplyManyPrompt([ + finding, + { ...finding, findingId: 'finding-2', path: 'src/b.ts' }, + ]); + expect(prompt).toContain('Apply these 2 review findings'); + expect(prompt).toContain('Review finding finding-2'); + expect(() => reviewApplyManyPrompt([finding, finding])).toThrow('unique'); + expect(() => reviewApplyManyPrompt([{ ...finding, findingId: 'unsafe\nid' }])).toThrow( + 'valid review finding', + ); + expect(() => reviewApplyManyPrompt([{ ...finding, title: 'unsafe\ntitle' }])).toThrow( + 'valid review finding', + ); + expect(() => reviewApplyManyPrompt([{ ...finding, endLine: 205 }])).toThrow( + 'valid review finding', + ); + }); }); diff --git a/packages/protocol/src/review.ts b/packages/protocol/src/review.ts index ab41b7a..cd790ae 100644 --- a/packages/protocol/src/review.ts +++ b/packages/protocol/src/review.ts @@ -5,36 +5,86 @@ import type { ReviewFindingPayload } from './types.js'; * endpoint, so the normal permission/hook/sandbox/snapshot pipeline remains in force. */ export function reviewApplyPrompt(finding: ReviewFindingPayload): string { + return reviewApplyManyPrompt([finding]); +} + +/** Build one canonical, permission-gated turn for a bounded set of findings. */ +export function reviewApplyManyPrompt(findings: ReviewFindingPayload[]): string { + if (!Array.isArray(findings) || findings.length === 0 || findings.length > 20) { + throw new Error('Between 1 and 20 valid review findings are required'); + } + const seen = new Set(); + for (const finding of findings) { + assertReviewFinding(finding); + if (seen.has(finding.findingId)) throw new Error('Review finding ids must be unique'); + seen.add(finding.findingId); + } + const sections = findings.map((finding) => { + const replacement = finding.replacement + ? `\nSuggested replacement:\n${finding.replacement}` + : ''; + return ( + `Review finding ${finding.findingId}: ${finding.title}\n` + + `Location: ${JSON.stringify(finding.path)}:${finding.startLine}-${finding.endLine}\n` + + `${finding.body}${replacement}` + ); + }); + const prompt = + (findings.length === 1 + ? `Apply review finding ${findings[0]!.findingId}: ${findings[0]!.title}\n` + + sections[0]!.split('\n').slice(1).join('\n') + : `Apply these ${findings.length} review findings in one focused pass:\n\n${sections.join('\n\n')}`) + + (findings.length === 1 + ? '\n\nRe-read the file and verify the finding is still current. Make only the minimal safe change, ' + + 'using the normal editing tools. If the code has changed or the finding is invalid, explain and do not edit.' + : '\n\nRe-read every file and verify each finding is still current. Make only the minimal safe changes, ' + + 'using the normal editing tools. If code has changed or a finding is invalid, explain and skip it.'); + if (prompt.length > 128 * 1024) throw new Error('Review apply prompt is too large'); + return prompt; +} + +export function isReviewFindingPayload(value: unknown): value is ReviewFindingPayload { + try { + assertReviewFinding(value); + return true; + } catch { + return false; + } +} + +function assertReviewFinding(finding: unknown): asserts finding is ReviewFindingPayload { + if (!finding || typeof finding !== 'object') { + throw new Error('A valid review finding is required'); + } + const candidate = finding as Partial; if ( - !finding || - typeof finding.findingId !== 'string' || - !finding.findingId || - typeof finding.title !== 'string' || - finding.title.length === 0 || - finding.title.length > 160 || - typeof finding.body !== 'string' || - finding.body.length === 0 || - finding.body.length > 4000 || - typeof finding.path !== 'string' || - !safeRelativePath(finding.path) || - !Number.isInteger(finding.startLine) || - !Number.isInteger(finding.endLine) || - finding.startLine < 1 || - finding.endLine < finding.startLine || - ![0, 1, 2, 3].includes(finding.priority) || - (finding.replacement !== undefined && - (typeof finding.replacement !== 'string' || finding.replacement.length > 32 * 1024)) + typeof candidate.findingId !== 'string' || + !/^[a-zA-Z0-9._-]{1,200}$/.test(candidate.findingId) || + typeof candidate.title !== 'string' || + candidate.title.length === 0 || + candidate.title.length > 160 || + hasControlCharacter(candidate.title) || + typeof candidate.body !== 'string' || + candidate.body.length === 0 || + candidate.body.length > 4000 || + typeof candidate.path !== 'string' || + !safeRelativePath(candidate.path) || + !Number.isInteger(candidate.startLine) || + !Number.isInteger(candidate.endLine) || + candidate.startLine! < 1 || + candidate.endLine! < candidate.startLine! || + candidate.endLine! - candidate.startLine! > 200 || + ![0, 1, 2, 3].includes(candidate.priority as number) || + (candidate.replacement !== undefined && + (typeof candidate.replacement !== 'string' || + utf8ByteLength(candidate.replacement) > 32 * 1024)) ) { throw new Error('A valid review finding is required'); } - const replacement = finding.replacement ? `\nSuggested replacement:\n${finding.replacement}` : ''; - return ( - `Apply review finding ${finding.findingId}: ${finding.title}\n` + - `Location: ${JSON.stringify(finding.path)}:${finding.startLine}-${finding.endLine}\n` + - `${finding.body}${replacement}\n\n` + - 'Re-read the file and verify the finding is still current. Make only the minimal safe change, ' + - 'using the normal editing tools. If the code has changed or the finding is invalid, explain and do not edit.' - ); +} + +function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; } function safeRelativePath(path: string): boolean { diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts index b50dbdf..faad833 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, + reviewActions: false, configDiagnostics: false, diagnosticExport: false, workspaceDiff: false, diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index 3ac0c76..e264d88 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -44,6 +44,7 @@ export interface ProtocolRuntimeOptions { configDiagnostics?: boolean; diagnosticExport?: boolean; workspaceDiff?: boolean; + reviewActions?: boolean; } export class ProtocolInvariantError extends Error { @@ -82,6 +83,7 @@ export class ProtocolRuntime { configDiagnostics: this.options.configDiagnostics ?? false, diagnosticExport: this.options.diagnosticExport ?? false, workspaceDiff: this.options.workspaceDiff ?? false, + reviewActions: this.options.reviewActions ?? false, }, }; } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index a1d1ea9..ca9d1d7 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -9,6 +9,7 @@ export type CompletedItemType = | 'approval' | 'ask_user' | 'review_finding' + | 'review_action' | 'error'; export interface CompletedItem { @@ -135,6 +136,7 @@ export interface InitializeResult { configDiagnostics: boolean; diagnosticExport: boolean; workspaceDiff: boolean; + reviewActions: boolean; }; } @@ -213,11 +215,18 @@ export interface ReviewFindingPayload { replacement?: string; } +export interface ReviewActionPayload { + actionId: string; + kind: 'apply'; + findingIds: string[]; +} + export type ProtocolMethod = | 'initialize' | 'config/diagnostics' | 'diagnostics/export' | 'workspace/diff' + | 'review/apply' | 'thread/start' | 'thread/read' | 'thread/resume' From 27b3d109b0bd42a0fd74e267831393713f13338c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 17:27:49 +0800 Subject: [PATCH 31/33] feat: add conflict-safe review revert --- apps/desktop/src/lib/protocol-agent.test.ts | 12 + apps/desktop/src/lib/protocol-agent.ts | 16 ++ apps/lsp/README.md | 1 + apps/lsp/src/handler.test.ts | 17 +- apps/lsp/src/handler.ts | 27 +++ apps/server/README.md | 6 + apps/server/src/default-runtime.ts | 1 + apps/server/src/runtime-composition.test.ts | 19 ++ apps/server/src/runtime-composition.ts | 3 + apps/server/src/runtime-executor.test.ts | 36 ++- apps/server/src/runtime-executor.ts | 12 +- apps/server/src/server.test.ts | 41 ++++ apps/server/src/server.ts | 62 +++++- apps/vscode/README.md | 5 +- apps/vscode/package.json | 4 + apps/vscode/src/extension.ts | 37 ++++ apps/vscode/src/protocol-runtime.test.ts | 14 +- apps/vscode/src/protocol-runtime.ts | 17 ++ docs/CODEX_ALIGNMENT_PLAN.md | 9 +- docs/design/app-server-v1.md | 6 + packages/core/src/agent.test.ts | 25 +++ packages/core/src/agent.ts | 40 +++- packages/core/src/index.ts | 1 + packages/core/src/sessions/manager.ts | 2 + packages/core/src/sessions/snapshots.test.ts | 7 + packages/core/src/sessions/snapshots.ts | 19 ++ packages/core/src/tools/index.ts | 1 + .../src/tools/restore-review-action.test.ts | 131 +++++++++++ .../core/src/tools/restore-review-action.ts | 206 ++++++++++++++++++ packages/core/src/types.ts | 2 + packages/protocol/README.md | 5 + packages/protocol/src/codec.test.ts | 1 + packages/protocol/src/codec.ts | 1 + packages/protocol/src/review.test.ts | 9 +- packages/protocol/src/review.ts | 19 ++ packages/protocol/src/types.ts | 11 +- 36 files changed, 801 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/tools/restore-review-action.test.ts create mode 100644 packages/core/src/tools/restore-review-action.ts diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index e635a0c..79220e2 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -47,6 +47,7 @@ class FakeTransport implements ProtocolTransport { if (method === 'thread/resume') return thread as T; if (method === 'turn/start') return turn as T; if (method === 'review/apply') return turn as T; + if (method === 'review/revert') return turn as T; if (method === 'turn/interrupt') return { interrupted: true } as T; if (method === 'config/diagnostics') return diagnostics as T; if (method === 'workspace/diff') return workspaceDiff as T; @@ -120,6 +121,17 @@ describe('DesktopProtocolAgent', () => { }); }); + it('reverts a review action through the canonical conflict-safe turn', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await agent.resume(thread.id); + await agent.revertAction('turn-apply'); + expect(transport.requests.at(-1)).toEqual({ + method: 'review/revert', + params: { threadId: thread.id, actionId: 'turn-apply' }, + }); + }); + it('reads value-free diagnostics from the shared app-server', async () => { const transport = new FakeTransport(); const agent = new DesktopProtocolAgent(transport, () => undefined); diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts index 8723ae7..1122580 100644 --- a/apps/desktop/src/lib/protocol-agent.ts +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -123,6 +123,22 @@ export class DesktopProtocolAgent { return { turnId: turn.id, threadId }; } + async revertAction(actionId: string) { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const threadId = this.threadId; + if (!threadId) throw new Error('No active workspace thread'); + const turn = await this.transport.request('review/revert', { + threadId, + actionId, + }); + this.activeTurns.set(turn.id, threadId); + setTimeout(() => this.flushTurn(turn.id), 0); + return { turnId: turn.id, threadId }; + } + clear(): void { void this.interruptActiveTurns(); this.threadId = null; diff --git a/apps/lsp/README.md b/apps/lsp/README.md index 5d5dcd4..a612190 100644 --- a/apps/lsp/README.md +++ b/apps/lsp/README.md @@ -19,6 +19,7 @@ LSP plugin) can drive DeepCode via `workspace/executeCommand`. | `deepcode.workspaceDiff` | none | canonical structured workspace diff | | `deepcode.applyReviewFinding` | `{ findingId }` | `{ threadId, turnId }` | | `deepcode.applyReviewFindings` | `{ findingIds }` | `{ threadId, turnId }` | +| `deepcode.revertReviewAction` | `{ actionId }` | `{ threadId, turnId }` | Lifecycle, structured tool, usage, approval, and user-input events are sent unchanged as `deepcode/protocolEvent` notifications: diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index 1c6c7a3..c8364bd 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -83,7 +83,8 @@ class FakeClient { case 'thread/resume': return this.thread as T; case 'turn/start': - case 'review/apply': { + case 'review/apply': + case 'review/revert': { // Deliberately precedes the response to exercise the LSP fast-turn queue. this.emit({ type: 'turn.started', threadId: this.thread.id, turn: this.turn }); queueMicrotask(() => { @@ -162,6 +163,7 @@ describe('handleMessage — initialize', () => { 'deepcode.workspaceDiff', 'deepcode.applyReviewFinding', 'deepcode.applyReviewFindings', + 'deepcode.revertReviewAction', ]), ); }); @@ -242,6 +244,19 @@ describe('handleMessage — protocol commands', () => { }); }); + it('reverts a review action through the canonical conflict-safe turn', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + await execute(23, 'deepcode.revertReviewAction', { actionId: 'turn-apply' }, (message) => + out.push(message), + ); + expect(client.requests.at(-1)).toMatchObject({ + method: 'review/revert', + params: { threadId: 'thread-1', actionId: 'turn-apply' }, + }); + }); + 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 4760ce7..2991ee4 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -71,6 +71,7 @@ const COMMANDS = [ 'deepcode.workspaceDiff', 'deepcode.applyReviewFinding', 'deepcode.applyReviewFindings', + 'deepcode.revertReviewAction', ]; export async function handleMessage(msg: LspMessage, send: SendFn): Promise { @@ -187,6 +188,11 @@ async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): ((params.arguments?.[0] ?? {}) as { findingIds?: string[] }).findingIds ?? [], send, ); + case 'deepcode.revertReviewAction': + return handleReviewRevert( + ((params.arguments?.[0] ?? {}) as { actionId?: string }).actionId, + send, + ); default: throw new Error(`Unknown command: ${params.command}`); } @@ -243,6 +249,27 @@ async function handleReviewApply( return { threadId: thread.id, turnId: turn.id }; } +async function handleReviewRevert( + actionId: string | undefined, + send: SendFn, +): Promise<{ threadId: string; turnId: string }> { + if (!actionId) throw new Error('actionId is required'); + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const thread = await ensureThread(client); + const turn = await client.request('review/revert', { + threadId: thread.id, + actionId, + }); + state.activeTurns.set(turn.id, thread.id); + state.turnSinks.set(turn.id, send); + flushEvents(turn.id); + return { threadId: thread.id, turnId: turn.id }; +} + 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); diff --git a/apps/server/README.md b/apps/server/README.md index 69119eb..62b65ac 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -54,3 +54,9 @@ with a workspace-relative path, tight line range, priority, and optional exact r canonical thread, builds the verification-first prompt in the host, and records a `review_action` item tied to the new turn. It is not a filesystem endpoint: every edit still uses the existing Edit/Write permission, approval, hook, sandbox, and snapshot path. + +Every app-server snapshot is tagged with its canonical turn id. `review/revert` resolves a completed +Apply action and starts a tool-restricted turn; only `RestoreReviewAction` is exposed. That tool +performs an all-files compare-and-swap against the action's post-images before restoring pre-images, +then snapshots the restore itself. It refuses conflicts and never falls back to `git checkout` or an +unscoped write. diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 6b0a019..e727c89 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -54,6 +54,7 @@ export function createDefaultTurnExecutor( provider, requestApproval: context.requestApproval, signal: context.signal, + includeReviewRestore: context.reviewAction?.kind === 'revert', }); return { host: new RuntimeHost({ diff --git a/apps/server/src/runtime-composition.test.ts b/apps/server/src/runtime-composition.test.ts index ade68b9..6ce6d0c 100644 --- a/apps/server/src/runtime-composition.test.ts +++ b/apps/server/src/runtime-composition.test.ts @@ -28,6 +28,25 @@ describe('composeRuntime', () => { expect(resolveComposedMode('auto', true, settings)).toBe('auto'); }); + it('registers the internal restore tool only for canonical revert turns', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-composition-revert-')); + roots.push(cwd); + const normal = await composeRuntime({ + cwd, + settings: { plugins: { globalEnabled: false } }, + }); + expect(normal.tools.get('RestoreReviewAction')).toBeUndefined(); + await normal.close(); + + const revert = await composeRuntime({ + cwd, + settings: { plugins: { globalEnabled: false } }, + includeReviewRestore: true, + }); + expect(revert.tools.get('RestoreReviewAction')).toBeDefined(); + await revert.close(); + }); + 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-')); diff --git a/apps/server/src/runtime-composition.ts b/apps/server/src/runtime-composition.ts index e94941a..7b2db53 100644 --- a/apps/server/src/runtime-composition.ts +++ b/apps/server/src/runtime-composition.ts @@ -27,6 +27,7 @@ import { BUILTIN_TOOLS, installToolSearch, ReadTool, + RestoreReviewActionTool, ToolRegistry, WebFetchTool, WriteTool, @@ -77,6 +78,7 @@ export interface RuntimeCompositionOptions { requestApproval?: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; signal?: AbortSignal; services?: Partial; + includeReviewRestore?: boolean; } export interface RuntimeComposition { @@ -146,6 +148,7 @@ export async function composeRuntime( ]); const tools = new ToolRegistry(BUILTIN_TOOLS); + if (options.includeReviewRestore) tools.register(RestoreReviewActionTool); if (skills.length > 0) tools.register(makeSkillTool(skills)); const hooks = new HookDispatcher({ hooks: settings.hooks, diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts index a246824..ec50ead 100644 --- a/apps/server/src/runtime-executor.test.ts +++ b/apps/server/src/runtime-executor.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { RuntimeHost, + RestoreReviewActionTool, SessionManager, ToolRegistry, type AgentEvent, @@ -106,6 +107,37 @@ class ToolProvider implements Provider { } describe('RuntimeHostExecutor', () => { + it('exposes only the restore tool to a canonical revert turn', async () => { + const provider = new StreamingProvider(); + const tools = new ToolRegistry(); + tools.register(RestoreReviewActionTool); + const host = new RuntimeHost({ + provider, + tools, + cwd: '/workspace', + }); + const executor = new RuntimeHostExecutor({ createHost: () => host }); + await executor.execute({ + thread, + turn: { + id: 'turn-revert', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { + text: 'revert', + reviewAction: { kind: 'revert', sourceActionId: 'turn-apply', findingIds: ['finding-1'] }, + }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }); + + expect(provider.seenOptions?.tools.map((tool) => tool.name)).toEqual(['RestoreReviewAction']); + }); + it('projects validated review tool results into durable finding items', () => { const events: AgentEvent[] = [ { @@ -452,7 +484,9 @@ describe('RuntimeHostExecutor', () => { }); await expect(sessions.load('thread-snapshots')).resolves.toBeNull(); - await expect(sessions.snapshots('thread-snapshots')).resolves.toHaveLength(2); + const snapshots = await sessions.snapshots('thread-snapshots'); + expect(snapshots).toHaveLength(2); + expect(snapshots.every((snapshot) => snapshot.turnId === 'turn-snapshots')).toBe(true); } 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 0dacd5d..17720e8 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -27,6 +27,7 @@ export interface RuntimeHostCreationContext { modeExplicit: boolean; signal: AbortSignal; requestApproval: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; + reviewAction: { kind: 'apply' | 'revert' } | null; } export interface RuntimeHostLease { @@ -62,6 +63,7 @@ export class RuntimeHostExecutor implements TurnExecutor { const requestedMode = args.input.mode; const modeExplicit = isMode(requestedMode); const mode: Mode = modeExplicit ? requestedMode : 'default'; + const reviewAction = readReviewAction(args.input.reviewAction); const interactionItems: TurnExecutionItem[] = []; const requestApproval = async (toolName: string, reason: string) => { const decision = await args.requestApproval(toolName, reason); @@ -75,6 +77,7 @@ export class RuntimeHostExecutor implements TurnExecutor { modeExplicit, signal: args.signal, requestApproval, + reviewAction, }); const lease: RuntimeHostLease = 'host' in created ? created : { host: created }; try { @@ -106,9 +109,10 @@ export class RuntimeHostExecutor implements TurnExecutor { : (lease.model ?? this.options.model ?? 'deepseek-chat'), maxTokens: effortParams.maxTokens, temperature: effortParams.temperature, + ...(reviewAction?.kind === 'revert' ? { allowedTools: ['RestoreReviewAction'] } : {}), signal: args.signal, session: this.options.sessionManager - ? { manager: this.options.sessionManager, id: args.thread.id } + ? { manager: this.options.sessionManager, id: args.thread.id, turnId: args.turn.id } : undefined, persistSessionMessages: false, systemReminders: false, @@ -170,6 +174,12 @@ export class RuntimeHostExecutor implements TurnExecutor { } } +function readReviewAction(value: unknown): { kind: 'apply' | 'revert' } | null { + if (!value || typeof value !== 'object') return null; + const kind = (value as { kind?: unknown }).kind; + return kind === 'apply' || kind === 'revert' ? { kind } : null; +} + export function reviewFindingsFromEvents(events: AgentEvent[]): TurnExecutionItem[] { const calls = new Map>(); const items: TurnExecutionItem[] = []; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3b8eff2..988b797 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -53,6 +53,7 @@ describe('AppServer', () => { }, ], }) + .mockResolvedValueOnce({}) .mockResolvedValueOnce({}), }; const server = new AppServer({ executor, ...deterministicOptions() }); @@ -87,6 +88,40 @@ describe('AppServer', () => { ]), }), ); + + const reverted = await server.handle( + request(5, 'review/revert', { threadId, actionId: turnId }), + ); + const revertTurnId = (reverted.result as { id: string }).id; + await server.waitForIdle(); + const afterRevert = await server.handle(request(6, 'thread/read', { threadId })); + expect((afterRevert.result as ThreadSnapshot).turns.at(-1)).toEqual( + expect.objectContaining({ + id: revertTurnId, + items: expect.arrayContaining([ + expect.objectContaining({ + type: 'user_message', + payload: expect.objectContaining({ + text: expect.stringContaining('RestoreReviewAction exactly once'), + reviewAction: { + kind: 'revert', + sourceActionId: turnId, + findingIds: ['finding-1'], + }, + }), + }), + expect.objectContaining({ + type: 'review_action', + payload: { + actionId: revertTurnId, + kind: 'revert', + sourceActionId: turnId, + findingIds: ['finding-1'], + }, + }), + ]), + }), + ); }); it('rejects unknown findings, duplicate batches, and direct review metadata injection', async () => { @@ -117,6 +152,12 @@ describe('AppServer', () => { id: 4, error: expect.objectContaining({ code: 'invalid_request' }), }); + await expect( + server.handle(request(5, 'review/revert', { threadId, actionId: 'missing' })), + ).resolves.toEqual({ + id: 5, + error: expect.objectContaining({ code: 'invalid_request' }), + }); }); it('binds workspace diff reads to the canonical thread cwd', async () => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 146bbe3..5dfe261 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,7 @@ import { ProtocolInvariantError, ProtocolRuntime, reviewApplyManyPrompt, + reviewRevertPrompt, type CompletedItemType, type ConfigDiagnosticsResult, type DiagnosticExportResult, @@ -11,6 +12,7 @@ import { type ProtocolRequest, type ProtocolResponse, type ReviewActionPayload, + type ReviewActionRequest, type ReviewFindingPayload, type ThreadSnapshot, type ThreadStore, @@ -213,6 +215,8 @@ export class AppServer { } case 'review/apply': return this.applyReviewFindings(request.params, traceId); + case 'review/revert': + return this.revertReviewAction(request.params, traceId); case 'thread/start': return this.lifecycle.startThread(requiredString(request.params, 'cwd'), traceId); case 'thread/read': @@ -262,7 +266,7 @@ export class AppServer { if (!finding) throw new RequestValidationError(`Review finding not found: ${findingId}`); return finding; }); - const action: Omit = { kind: 'apply', findingIds }; + const action: ReviewActionRequest = { kind: 'apply', findingIds }; return this.startTurn( { threadId, @@ -273,10 +277,46 @@ export class AppServer { ); } + private async revertReviewAction( + params: Record, + traceId: string, + ): Promise { + const threadId = requiredId(params, 'threadId'); + const sourceActionId = requiredId(params, 'actionId'); + const thread = await this.lifecycle.resumeThread(threadId); + const sourceTurn = thread.turns.find((turn) => turn.id === sourceActionId); + if (!sourceTurn || sourceTurn.status !== 'completed') { + throw new RequestValidationError(`Completed review action not found: ${sourceActionId}`); + } + const sourceAction = sourceTurn.items + .filter((item) => item.type === 'review_action') + .map((item) => item.payload) + .find(isApplyReviewAction); + if (!sourceAction || sourceAction.actionId !== sourceActionId) { + throw new RequestValidationError(`Applied review action not found: ${sourceActionId}`); + } + const action: ReviewActionRequest = { + kind: 'revert', + sourceActionId, + findingIds: sourceAction.findingIds, + }; + return this.startTurn( + { + threadId, + input: { + text: reviewRevertPrompt(sourceActionId, sourceAction.findingIds), + reviewAction: action, + }, + }, + traceId, + action, + ); + } + private async startTurn( params: Record, traceId: string, - reviewAction?: Omit, + reviewAction?: ReviewActionRequest, ): Promise { const threadId = requiredId(params, 'threadId'); const input = requiredRecord(params, 'input'); @@ -589,7 +629,7 @@ function requiredString(params: Record, key: string): string { function requiredId(params: Record, key: string): string { const value = requiredString(params, key); - if (!/^[a-zA-Z0-9._-]+$/.test(value)) { + if (value.length > 200 || !/^[a-zA-Z0-9._-]+$/.test(value)) { throw new RequestValidationError(`${key} is invalid`); } return value; @@ -619,3 +659,19 @@ function requiredIds(params: Record, key: string, maximum: numb } return ids; } + +function isApplyReviewAction(value: Record): value is ReviewActionPayload & { + kind: 'apply'; +} { + return ( + value.kind === 'apply' && + typeof value.actionId === 'string' && + Array.isArray(value.findingIds) && + value.findingIds.length > 0 && + value.findingIds.length <= 20 && + new Set(value.findingIds).size === value.findingIds.length && + value.findingIds.every( + (findingId) => typeof findingId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(findingId), + ) + ); +} diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 7d0939c..0792f13 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 -- Six commands, an activity-bar chat view, model/effort settings, and a default +- Seven 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. @@ -45,6 +45,7 @@ Then: | `deepcode.review` | (palette) | Run `code-review` skill on current diff | | `deepcode.applyReviewFinding` | (API/context) | Apply one canonical finding through a normal agent turn | | `deepcode.applyAllReviewFindings` | (palette) | Apply the latest review batch through one canonical turn | +| `deepcode.revertReviewAction` | (palette) | Conflict-safely revert the latest applied review action | | `deepcode.showDiagnostics` | (palette) | Show value-free config sources, trust gates, and issues | ## Settings @@ -60,7 +61,7 @@ trusted `settings.json` model and effort remain authoritative. ## Roadmap -- Inline review comments, context actions, and per-finding revert controls +- Inline review comments and editor-context actions - File panel showing live edits as the agent works - Inline webview approval cards (host-native warning actions work today) - Custom commands via skills (mirror CLI's `/skills` dir) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 60be057..af847fe 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -48,6 +48,10 @@ "command": "deepcode.applyAllReviewFindings", "title": "DeepCode: Apply All Review Findings" }, + { + "command": "deepcode.revertReviewAction", + "title": "DeepCode: Revert Latest Review Action" + }, { "command": "deepcode.showDiagnostics", "title": "DeepCode: Show Configuration Diagnostics" diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index b9b586a..ac5b883 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -5,6 +5,7 @@ import { isReviewFindingPayload, ProtocolClient, type ProtocolEvent, + type ReviewActionPayload, type ReviewFindingPayload, } from '@deepcode/protocol'; import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; @@ -18,6 +19,7 @@ type V = typeof import('vscode'); let activeRuntime: EditorProtocolRuntime | undefined; const latestReviewFindings = new Map(); +let latestAppliedActionId: string | undefined; export async function activate(context: vscode.ExtensionContext): Promise { const vscodeMod = await loadVscode(); @@ -96,6 +98,17 @@ export async function activate(context: vscode.ExtensionContext): Promise } await runFindingsInOutput(findings, vscodeMod, runtime); }), + commands.registerCommand( + 'deepcode.revertReviewAction', + async (action: Pick | undefined) => { + const actionId = action?.actionId ?? latestAppliedActionId; + if (!actionId) { + void window.showInformationMessage('DeepCode: no applied review action to revert.'); + return; + } + await runRevertInOutput(actionId, vscodeMod, runtime); + }, + ), commands.registerCommand('deepcode.showDiagnostics', async () => { const out = window.createOutputChannel('DeepCode Diagnostics'); out.show(true); @@ -132,6 +145,24 @@ async function runFindingsInOutput( } } +async function runRevertInOutput( + actionId: string, + vscodeMod: V, + runtime: EditorProtocolRuntime, +): Promise { + const out = vscodeMod.window.createOutputChannel('DeepCode'); + out.show(true); + out.appendLine(`Reverting review action: ${actionId}`); + try { + await runtime.revertAction(actionId, (event) => { + projectOutputEvent(event, out); + void respondToInteraction(event, vscodeMod, runtime); + }); + } catch (error) { + out.appendLine(`\n✕ ${(error as Error).message ?? String(error)}`); + } +} + export async function deactivate(): Promise { const runtime = activeRuntime; activeRuntime = undefined; @@ -208,6 +239,12 @@ function projectOutputEvent(event: ProtocolEvent, out: vscode.OutputChannel): vo ); out.appendLine(` ${String(finding.body)}`); } else if (event.item.type === 'review_action') { + if ( + event.item.payload.kind === 'apply' && + typeof event.item.payload.actionId === 'string' + ) { + latestAppliedActionId = event.item.payload.actionId; + } out.appendLine( `\n[review action] ${String(event.item.payload.kind)} ${String( (event.item.payload.findingIds as unknown[] | undefined)?.length ?? 0, diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index 25974bb..21d384f 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -59,7 +59,7 @@ class FakeClient { if (method === 'thread/start' || method === 'thread/read' || method === 'thread/resume') { return this.thread as T; } - if (method === 'turn/start' || method === 'review/apply') { + if (method === 'turn/start' || method === 'review/apply' || method === 'review/revert') { this.emit({ type: 'turn.started', threadId: this.thread.id, turn: this.turn }); this.emit({ type: 'item.delta', @@ -136,6 +136,18 @@ describe('EditorProtocolRuntime', () => { }); }); + it('reverts a review action through the canonical conflict-safe turn', async () => { + const client = new FakeClient(); + const runtime = new EditorProtocolRuntime(client, () => '/workspace'); + await runtime.revertAction('turn-apply', () => undefined); + expect(client.requests.at(-1)).toEqual( + expect.objectContaining({ + method: 'review/revert', + params: { threadId: 'thread-1', actionId: 'turn-apply' }, + }), + ); + }); + it('reads configuration diagnostics from the app-server for the editor workspace', 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 4ad68ae..c536935 100644 --- a/apps/vscode/src/protocol-runtime.ts +++ b/apps/vscode/src/protocol-runtime.ts @@ -116,6 +116,23 @@ export class EditorProtocolRuntime { return { threadId: thread.id, turnId: turn.id }; } + async revertAction( + actionId: string, + onEvent: EventHandler, + ): Promise<{ threadId: string; turnId: string }> { + const initialized = await this.client.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const thread = await this.ensureThread(); + const turn = await this.client.request('review/revert', { + threadId: thread.id, + actionId, + }); + this.trackTurn(turn, thread.id, onEvent); + return { threadId: thread.id, turnId: turn.id }; + } + 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 9041b41..4b15c3a 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -338,9 +338,14 @@ model tool call 解析原始 payload、生成单项或批量 prompt,并把 finding/action/turn 关联持久化为 `review_action`。Apply 不暴露直写接口,因此继续经过 Edit/Write permission、approval、hook、 sandbox 与 snapshot,客户端不能篡改 path/replacement 或伪造 finding。 +- 每个 app-server turn 的文件快照已带 canonical turn id;`review/revert` 只解析已完成的 Apply + action,并把 revert turn 的工具上限锁定为 `RestoreReviewAction`。该工具在任何写入前校验 + 所有 current file 仍逐字节等于 Apply post-image、snapshot blob 完整且路径未逃逸;有冲突、 + Bash checkpoint 或 legacy 不完整快照时整体拒绝。恢复仍经过 permission/approval/hooks,且 + 自身产生 pre/post 快照;新建文件可按 `existed=false` 安全删除。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 -- 客户端内联评论/上下文 action 与冲突安全的单项 revert;VS Code review all 已由同一 - canonical action path 提供。 +- 客户端内联评论/上下文 action;VS Code review all 与 latest-action revert 已由同一 + canonical action path 提供,Desktop/LSP 暴露对应协议能力。 - 删除完成迁移的旧 IPC/facade;更新所有用户文档。 - release candidate、迁移演练、性能预算和回滚说明。 diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index 1b404a9..fa80147 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -47,6 +47,7 @@ by expecting partial deltas to replay. | `diagnostics/export` | workspace cwd | redacted local diagnostic bundle metadata | | `workspace/diff` | `threadId` | bounded structured workspace diff | | `review/apply` | `threadId`, `findingIds` | permission-gated review action turn | +| `review/revert` | `threadId`, `actionId` | conflict-safe restore action turn | `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 @@ -61,6 +62,11 @@ thread, then generates the bounded verification prompt inside the app-server. It item records the selected ids and action turn. Direct `turn/start` requests cannot inject this reserved metadata. +`review/revert` resolves only a completed Apply action. Its turn exposes only the +`RestoreReviewAction` mutation tool; unadvertised Edit/Write/Bash calls are rejected by the agent +loop even if a provider attempts them. Snapshot pairs carry the source turn id, and restoration is +an all-files compare-and-swap against the Apply post-images before any pre-image is written. + ## Storage and security The Node-specific `CanonicalThreadStore` writes one mode-0600 lifecycle snapshot per thread under diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 6e24040..c21055c 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -134,6 +134,31 @@ describe('runAgent', () => { expect(completed[0]).toMatchObject({ stopReason: 'end_turn' }); }); + it('enforces a host-owned per-turn tool ceiling', async () => { + const provider = new MockProvider([ + toolUse('trying a forbidden write', { + type: 'tool_use', + id: 'call-forbidden', + name: 'Write', + input: { file_path: 'forbidden.txt', content: 'nope' }, + }), + endTurn('stopped'), + ]); + const result = await runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'restricted turn', + model: 'deepseek-chat', + cwd, + allowedTools: ['Read'], + }); + + expect(provider.received[0]?.tools.map((tool) => tool.name)).toEqual(['Read']); + expect(JSON.stringify(result.history)).toContain('tool not allowed in this turn: Write'); + await expect(fs.access(join(cwd, 'forbidden.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('handles unknown tool gracefully', async () => { const provider = new MockProvider([ toolUse('using nope', { diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index ca731dc..86351b1 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -58,11 +58,13 @@ export interface RunAgentOptions { signal?: AbortSignal; onEvent?: (event: AgentEvent) => void; /** Optional: persist each turn to a session. */ - session?: { manager: SessionManager; id: string }; + session?: { manager: SessionManager; id: string; turnId?: string }; /** Keep session-backed snapshots while another owner materializes messages. */ persistSessionMessages?: boolean; /** Optional: snapshot files before/after Edit/Write tool calls. */ enableSnapshots?: boolean; + /** Optional host-owned per-turn tool ceiling; omitted means the full registry. */ + allowedTools?: string[]; /** Required dispatch mode. Every tool call goes through the central gate. */ mode: Mode; permissions?: PermissionRules; @@ -194,6 +196,7 @@ const READ_ONLY_TOOLS = new Set([ export async function runAgent(opts: RunAgentOptions): Promise { const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS; const runtimePolicy = resolveRuntimePolicy(opts); + const allowedToolNames = opts.allowedTools ? new Set(opts.allowedTools) : undefined; let history: StoredMessage[] = [...(opts.history ?? [])]; let snapshotSeq = (await opts.session?.manager.snapshots(opts.session.id))?.length ?? 0; @@ -261,6 +264,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { signal: opts.signal, sandboxConfig: opts.sandboxConfig, sessionDir: opts.session ? `${opts.session.manager.root}/${opts.session.id}` : undefined, + turnId: opts.session?.turnId, askUser: opts.askUser, modeSignal, }; @@ -308,15 +312,27 @@ export async function runAgent(opts: RunAgentOptions): Promise { definitions: () => opts.tools .definitions() - .filter((d) => !SUBAGENT_TOOL_DENYLIST.has(d.name) && (!allow || allow.has(d.name))), + .filter( + (d) => + !SUBAGENT_TOOL_DENYLIST.has(d.name) && + (!allow || allow.has(d.name)) && + (!allowedToolNames || allowedToolNames.has(d.name)), + ), get: (name: string) => - SUBAGENT_TOOL_DENYLIST.has(name) || (allow && !allow.has(name)) + SUBAGENT_TOOL_DENYLIST.has(name) || + (allow && !allow.has(name)) || + (allowedToolNames && !allowedToolNames.has(name)) ? undefined : opts.tools.get(name), list: () => opts.tools .list() - .filter((t) => !SUBAGENT_TOOL_DENYLIST.has(t.name) && (!allow || allow.has(t.name))), + .filter( + (t) => + !SUBAGENT_TOOL_DENYLIST.has(t.name) && + (!allow || allow.has(t.name)) && + (!allowedToolNames || allowedToolNames.has(t.name)), + ), } as typeof opts.tools; const sub = await runAgent({ @@ -479,7 +495,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { result = await opts.provider.runTurn({ model: opts.model, systemPrompt: opts.systemPrompt, - tools: opts.tools.definitions(), + tools: opts.tools + .definitions() + .filter((definition) => !allowedToolNames || allowedToolNames.has(definition.name)), // Snapshot the history slice — providers must not see mutations from // subsequent turns (and tests rely on the snapshot being stable). messages: [...history], @@ -557,12 +575,17 @@ export async function runAgent(opts: RunAgentOptions): Promise { // Phase 1 — sequential gate + approval. for (const toolUse of toolBlocks) { - const handler = opts.tools.get(toolUse.name); + const handler = + !allowedToolNames || allowedToolNames.has(toolUse.name) + ? opts.tools.get(toolUse.name) + : undefined; if (!handler) { resultsById.set(toolUse.id, { type: 'tool_result', tool_use_id: toolUse.id, - content: `Error: tool not found: ${toolUse.name}`, + content: allowedToolNames + ? `Error: tool not allowed in this turn: ${toolUse.name}` + : `Error: tool not found: ${toolUse.name}`, is_error: true, }); continue; @@ -631,6 +654,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { filePath, reason: `pre-${toolUse.name}`, seq: ++snapshotSeq, + turnId: opts.session.turnId, }); } } @@ -644,6 +668,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { cwd: opts.cwd, reason: 'pre-Bash', seq: ++snapshotSeq, + turnId: opts.session.turnId, }); } @@ -678,6 +703,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { filePath, reason: `post-${toolUse.name}`, seq: ++snapshotSeq, + turnId: opts.session.turnId, }); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2149716..eb78eba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,6 +38,7 @@ export { WebSearchTool, AskUserQuestionTool, SubmitReviewFindingTool, + RestoreReviewActionTool, ExitPlanModeTool, makeToolSearchTool, installToolSearch, diff --git a/packages/core/src/sessions/manager.ts b/packages/core/src/sessions/manager.ts index 7f8146b..2ed5e05 100644 --- a/packages/core/src/sessions/manager.ts +++ b/packages/core/src/sessions/manager.ts @@ -70,6 +70,7 @@ export class SessionManager { filePath: string; reason: string; seq: number; + turnId?: string; }): Promise { return captureSnapshot({ ...args, sessionsRoot: this.root }); } @@ -80,6 +81,7 @@ export class SessionManager { cwd: string; reason: string; seq: number; + turnId?: string; }): Promise { return captureGitCheckpoint({ ...args, sessionsRoot: this.root }); } diff --git a/packages/core/src/sessions/snapshots.test.ts b/packages/core/src/sessions/snapshots.test.ts index f6dae35..dab7f19 100644 --- a/packages/core/src/sessions/snapshots.test.ts +++ b/packages/core/src/sessions/snapshots.test.ts @@ -55,6 +55,7 @@ describe('snapshots', () => { }); expect(snap).toBeTruthy(); expect(snap?.size).toBe(16); + expect(snap?.existed).toBe(true); expect(snap?.reason).toBe('pre-Edit'); expect(snap?.filePath).toBe(path); expect(await fs.readFile(snap!.blobPath, 'utf8')).toBe('original content'); @@ -70,6 +71,10 @@ describe('snapshots', () => { seq: 1, }); expect(snap?.size).toBe(0); + expect(snap?.existed).toBe(false); + await fs.writeFile(join(cwd, 'missing.txt'), 'created later'); + await restoreSnapshot(snap!); + await expect(fs.access(join(cwd, 'missing.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('listSnapshots reads back manifest', async () => { @@ -82,6 +87,7 @@ describe('snapshots', () => { filePath: 'x.txt', reason: 'pre-Edit', seq: 1, + turnId: 'turn-1', }); await fs.writeFile(path, 'v2'); await captureSnapshot({ @@ -95,6 +101,7 @@ describe('snapshots', () => { const snaps = await listSnapshots({ sessionsRoot: root, sessionId: 'sid' }); expect(snaps).toHaveLength(2); expect(snaps[0]?.reason).toBe('pre-Edit'); + expect(snaps[0]?.turnId).toBe('turn-1'); expect(snaps[1]?.reason).toBe('post-Edit'); }); diff --git a/packages/core/src/sessions/snapshots.ts b/packages/core/src/sessions/snapshots.ts index 20947ec..9d0dc93 100644 --- a/packages/core/src/sessions/snapshots.ts +++ b/packages/core/src/sessions/snapshots.ts @@ -20,6 +20,10 @@ export interface Snapshot { size: number; /** Sequential within the session. */ seq: number; + /** Canonical app-server turn that caused this snapshot, when available. */ + turnId?: string; + /** Whether the file existed before capture; absent on legacy manifests. */ + existed?: boolean; /** Absolute path on disk where the snapshot blob is stored ('' for git kind). */ blobPath: string; /** @@ -55,15 +59,18 @@ export async function captureSnapshot(args: { filePath: string; reason: string; seq: number; + turnId?: string; }): Promise { const absPath = isAbsolute(args.filePath) ? args.filePath : resolve(args.cwd, args.filePath); let content: Buffer; + let existed = true; try { content = await fs.readFile(absPath); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { // file doesn't exist yet — record an empty snapshot so post-Write diff still works content = Buffer.from(''); + existed = false; } else { throw err; } @@ -84,6 +91,8 @@ export async function captureSnapshot(args: { hash, size: content.byteLength, seq: args.seq, + ...(args.turnId ? { turnId: args.turnId } : {}), + existed, blobPath, }; // also append to a per-session manifest for fast listing @@ -106,6 +115,7 @@ export async function captureGitCheckpoint(args: { cwd: string; reason: string; seq: number; + turnId?: string; }): Promise { try { if ((await git(args.cwd, ['rev-parse', '--is-inside-work-tree'])) !== 'true') return null; @@ -128,6 +138,7 @@ export async function captureGitCheckpoint(args: { hash: ref.slice(0, 16), size: 0, seq: args.seq, + ...(args.turnId ? { turnId: args.turnId } : {}), blobPath: '', kind: 'git', gitRef: ref, @@ -173,6 +184,14 @@ export async function restoreSnapshot(snap: Snapshot): Promise { await git(repo, ['checkout', snap.gitRef, '--', ...changed]); return changed; } + if (snap.existed === false) { + try { + await fs.unlink(snap.filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + return [snap.filePath]; + } const content = await fs.readFile(snap.blobPath); await fs.mkdir(dirname(snap.filePath), { recursive: true }); await fs.writeFile(snap.filePath, content); diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 2ca0d0b..0bd7a68 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -15,6 +15,7 @@ export { WebSearchTool, parseDuckDuckGoHtml } from './web-search.js'; export type { SearchHit } from './web-search.js'; export { AskUserQuestionTool } from './ask-user.js'; export { SubmitReviewFindingTool, type ReviewFinding } from './review-finding.js'; +export { RestoreReviewActionTool } from './restore-review-action.js'; export { ExitPlanModeTool } from './exit-plan.js'; export { CronCreateTool, CronListTool, CronDeleteTool } from './cron-tools.js'; export { diff --git a/packages/core/src/tools/restore-review-action.test.ts b/packages/core/src/tools/restore-review-action.test.ts new file mode 100644 index 0000000..a2f7b9d --- /dev/null +++ b/packages/core/src/tools/restore-review-action.test.ts @@ -0,0 +1,131 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { captureSnapshot, listSnapshots } from '../sessions/snapshots.js'; +import { RestoreReviewActionTool } from './restore-review-action.js'; + +describe('RestoreReviewActionTool', () => { + let root: string; + let cwd: string; + let sessionsRoot: string; + const sessionId = 'thread-1'; + const actionTurnId = 'turn-apply'; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'deepcode-review-revert-')); + cwd = join(root, 'workspace'); + sessionsRoot = join(root, 'sessions'); + await fs.mkdir(cwd); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('restores an unchanged post-image and snapshots the revert turn', async () => { + const filePath = join(cwd, 'a.txt'); + await fs.writeFile(filePath, 'before'); + await snapshot(filePath, 'pre-Edit', 1); + await fs.writeFile(filePath, 'after'); + await snapshot(filePath, 'post-Edit', 2); + + const result = await RestoreReviewActionTool.execute( + { action_turn_id: actionTurnId }, + { cwd, sessionDir: join(sessionsRoot, sessionId), turnId: 'turn-revert' }, + ); + + expect(result.isError).not.toBe(true); + expect(await fs.readFile(filePath, 'utf8')).toBe('before'); + const snapshots = await listSnapshots({ sessionsRoot, sessionId }); + expect(snapshots.slice(-2)).toEqual([ + expect.objectContaining({ reason: 'pre-RestoreReviewAction', turnId: 'turn-revert' }), + expect.objectContaining({ reason: 'post-RestoreReviewAction', turnId: 'turn-revert' }), + ]); + }); + + it('refuses atomically when the file changed after the action', async () => { + const filePath = join(cwd, 'a.txt'); + await fs.writeFile(filePath, 'before'); + await snapshot(filePath, 'pre-Edit', 1); + await fs.writeFile(filePath, 'after'); + await snapshot(filePath, 'post-Edit', 2); + await fs.writeFile(filePath, 'later user edit'); + + const result = await RestoreReviewActionTool.execute( + { action_turn_id: actionTurnId }, + { cwd, sessionDir: join(sessionsRoot, sessionId), turnId: 'turn-revert' }, + ); + + expect(result).toEqual(expect.objectContaining({ isError: true })); + expect(result.content).toContain('conflict'); + expect(await fs.readFile(filePath, 'utf8')).toBe('later user edit'); + }); + + it('deletes a file that did not exist before the action', async () => { + const filePath = join(cwd, 'new.txt'); + await snapshot(filePath, 'pre-Write', 1); + await fs.writeFile(filePath, 'created'); + await snapshot(filePath, 'post-Write', 2); + + const result = await RestoreReviewActionTool.execute( + { action_turn_id: actionTurnId }, + { cwd, sessionDir: join(sessionsRoot, sessionId), turnId: 'turn-revert' }, + ); + + expect(result.isError).not.toBe(true); + await expect(fs.access(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rejects a corrupted snapshot blob before writing', async () => { + const filePath = join(cwd, 'a.txt'); + await fs.writeFile(filePath, 'before'); + await snapshot(filePath, 'pre-Edit', 1); + await fs.writeFile(filePath, 'after'); + const post = await snapshot(filePath, 'post-Edit', 2); + await fs.writeFile(post!.blobPath, 'evil!'); + + const result = await RestoreReviewActionTool.execute( + { action_turn_id: actionTurnId }, + { cwd, sessionDir: join(sessionsRoot, sessionId), turnId: 'turn-revert' }, + ); + + expect(result).toEqual(expect.objectContaining({ isError: true })); + expect(result.content).toContain('integrity check failed'); + expect(await fs.readFile(filePath, 'utf8')).toBe('after'); + }); + + it('refuses to mutate a hard-linked workspace file', async () => { + const outside = join(root, 'outside.txt'); + const filePath = join(cwd, 'linked.txt'); + await fs.writeFile(outside, 'before'); + await fs.link(outside, filePath); + await snapshot(filePath, 'pre-Edit', 1); + await fs.writeFile(filePath, 'after'); + await snapshot(filePath, 'post-Edit', 2); + + const result = await RestoreReviewActionTool.execute( + { action_turn_id: actionTurnId }, + { cwd, sessionDir: join(sessionsRoot, sessionId), turnId: 'turn-revert' }, + ); + + expect(result).toEqual(expect.objectContaining({ isError: true })); + expect(result.content).toContain('hard-linked'); + expect(await fs.readFile(outside, 'utf8')).toBe('after'); + }); + + async function snapshot(filePath: string, reason: string, seq: number) { + return captureSnapshot({ + sessionsRoot, + sessionId, + cwd, + filePath, + reason, + seq, + turnId: actionTurnId, + }); + } +}); diff --git a/packages/core/src/tools/restore-review-action.ts b/packages/core/src/tools/restore-review-action.ts new file mode 100644 index 0000000..f367582 --- /dev/null +++ b/packages/core/src/tools/restore-review-action.ts @@ -0,0 +1,206 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; + +import { captureSnapshot, listSnapshots, type Snapshot } from '../sessions/snapshots.js'; +import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; + +interface RestoreCandidate { + filePath: string; + before: Snapshot; + beforeBytes: Buffer; + currentBytes: Buffer; +} + +const MAX_RESTORE_FILES = 100; +const MAX_RESTORE_FILE_BYTES = 16 * 1024 * 1024; +const MAX_RESTORE_TOTAL_BYTES = 64 * 1024 * 1024; + +/** Compare-and-swap restore for the exact Edit/Write footprint of one canonical turn. */ +export const RestoreReviewActionTool: ToolHandler = { + name: 'RestoreReviewAction', + definition: { + name: 'RestoreReviewAction', + description: + 'Conflict-safely revert the exact Edit/Write changes from one prior review action turn. ' + + 'Use only when asked to revert that action. Refuses if any affected file changed afterward.', + inputSchema: { + type: 'object', + properties: { + action_turn_id: { + type: 'string', + description: 'Canonical turn id recorded by the review_action item.', + }, + }, + required: ['action_turn_id'], + }, + }, + async execute(input: Record, ctx: ToolContext): Promise { + const actionTurnId = input.action_turn_id; + if (typeof actionTurnId !== 'string' || !/^[a-zA-Z0-9._-]{1,200}$/.test(actionTurnId)) { + return failure('action_turn_id is invalid'); + } + if (!ctx.sessionDir) return failure('session snapshots are unavailable'); + + try { + const sessionDir = resolve(ctx.sessionDir); + const sessionsRoot = dirname(sessionDir); + const sessionId = basename(sessionDir); + const snapshots = await listSnapshots({ sessionsRoot, sessionId }); + const scoped = snapshots.filter((snapshot) => snapshot.turnId === actionTurnId); + if (scoped.some((snapshot) => snapshot.kind === 'git')) { + return failure('the action used Bash; an exact file-level revert is unavailable'); + } + const candidates = await collectCandidates(scoped, ctx.cwd, sessionDir); + if (candidates.length === 0) { + return failure('no complete Edit/Write snapshot pairs were found for this action'); + } + + let sequence = snapshots.reduce((maximum, snapshot) => Math.max(maximum, snapshot.seq), 0); + for (const candidate of candidates) { + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd: ctx.cwd, + filePath: candidate.filePath, + reason: 'pre-RestoreReviewAction', + seq: ++sequence, + turnId: ctx.turnId, + }); + } + + const restored: RestoreCandidate[] = []; + try { + for (const candidate of candidates) { + await restoreBeforeImage(candidate); + restored.push(candidate); + } + } catch (error) { + await Promise.allSettled( + restored.map((candidate) => fs.writeFile(candidate.filePath, candidate.currentBytes)), + ); + throw error; + } + + const snapshotWarnings: string[] = []; + for (const candidate of candidates) { + try { + await captureSnapshot({ + sessionsRoot, + sessionId, + cwd: ctx.cwd, + filePath: candidate.filePath, + reason: 'post-RestoreReviewAction', + seq: ++sequence, + turnId: ctx.turnId, + }); + } catch (error) { + snapshotWarnings.push((error as Error).message); + } + } + const files = candidates.map((candidate) => relative(resolve(ctx.cwd), candidate.filePath)); + return { + content: `Restored review action ${actionTurnId}: ${files.join(', ')}${ + snapshotWarnings.length ? ' (post-restore snapshot warning)' : '' + }`, + data: { actionTurnId, files, snapshotWarnings }, + }; + } catch (error) { + return failure((error as Error).message); + } + }, +}; + +async function collectCandidates( + snapshots: Snapshot[], + cwd: string, + sessionDir: string, +): Promise { + const byFile = new Map(); + for (const snapshot of snapshots) { + if (snapshot.kind === 'git') continue; + const rows = byFile.get(snapshot.filePath) ?? []; + rows.push(snapshot); + byFile.set(snapshot.filePath, rows); + } + const candidates: RestoreCandidate[] = []; + if (byFile.size > MAX_RESTORE_FILES) { + throw new Error(`review action affects more than ${MAX_RESTORE_FILES} files`); + } + let totalBytes = 0; + for (const [filePath, rows] of byFile) { + rows.sort((left, right) => left.seq - right.seq); + const before = rows.find((snapshot) => snapshot.reason.startsWith('pre-')); + const after = [...rows].reverse().find((snapshot) => snapshot.reason.startsWith('post-')); + if (!before || !after || before.seq >= after.seq || before.existed === undefined) continue; + await assertWorkspaceFile(filePath, cwd); + const beforeBytes = await verifiedBlob(before, sessionDir); + const afterBytes = await verifiedBlob(after, sessionDir); + const currentBytes = await fs.readFile(filePath); + if (!currentBytes.equals(afterBytes)) { + throw new Error( + `conflict: ${relative(resolve(cwd), filePath)} changed after the review action`, + ); + } + totalBytes += beforeBytes.byteLength + currentBytes.byteLength; + if (totalBytes > MAX_RESTORE_TOTAL_BYTES) { + throw new Error('review action restore exceeds the total byte limit'); + } + candidates.push({ filePath, before, beforeBytes, currentBytes }); + } + return candidates; +} + +async function assertWorkspaceFile(filePath: string, cwd: string): Promise { + const lexicalWorkspace = resolve(cwd); + const absolute = resolve(filePath); + const workspaceRelative = relative(lexicalWorkspace, absolute); + if (!workspaceRelative || workspaceRelative.startsWith('..') || isAbsolute(workspaceRelative)) { + throw new Error(`snapshot path is outside the workspace: ${filePath}`); + } + const stat = await fs.lstat(absolute); + if (stat.isSymbolicLink()) throw new Error(`refusing to restore symlink: ${workspaceRelative}`); + if (!stat.isFile()) throw new Error(`refusing to restore non-file: ${workspaceRelative}`); + if (stat.nlink > 1) throw new Error(`refusing to restore hard-linked file: ${workspaceRelative}`); + if (stat.size > MAX_RESTORE_FILE_BYTES) { + throw new Error(`file exceeds the review restore byte limit: ${workspaceRelative}`); + } + const workspace = await fs.realpath(lexicalWorkspace); + const real = await fs.realpath(absolute); + const realRelative = relative(workspace, real); + if (!realRelative || realRelative.startsWith('..') || isAbsolute(realRelative)) { + throw new Error(`snapshot target escapes the workspace: ${workspaceRelative}`); + } +} + +async function verifiedBlob(snapshot: Snapshot, sessionDir: string): Promise { + const snapshotsDir = await fs.realpath(resolve(sessionDir, 'snapshots')); + const blob = await fs.realpath(snapshot.blobPath); + const blobRelative = relative(snapshotsDir, blob); + if (!blobRelative || blobRelative.startsWith('..') || isAbsolute(blobRelative)) { + throw new Error('snapshot blob is outside the session'); + } + const stat = await fs.lstat(blob); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error('snapshot blob is not a regular file'); + if (stat.size !== snapshot.size) + throw new Error('snapshot blob size does not match its manifest'); + if (stat.size > MAX_RESTORE_FILE_BYTES) throw new Error('snapshot blob exceeds the byte limit'); + const bytes = await fs.readFile(blob); + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16); + if (hash !== snapshot.hash) + throw new Error(`snapshot integrity check failed: ${snapshot.filePath}`); + return bytes; +} + +async function restoreBeforeImage(candidate: RestoreCandidate): Promise { + if (candidate.before.existed === false) { + await fs.unlink(candidate.filePath); + return; + } + await fs.writeFile(candidate.filePath, candidate.beforeBytes); +} + +function failure(message: string): ToolResult { + return { content: `Error: ${message}`, isError: true }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5e9fbb7..4abd4a8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -112,6 +112,8 @@ export interface ToolContext { cwd: string; /** Where to write session-scoped artifacts (snapshots, bg task logs, etc.). */ sessionDir?: string; + /** Canonical app-server turn associated with session-scoped mutations. */ + turnId?: string; /** Abort signal propagated from the agent loop. */ signal?: AbortSignal; /** Optional platform sandbox config — passed through to Bash tool (M3.5). */ diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 6b803ca..6b4db4b 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -26,3 +26,8 @@ only finding ids already present in the canonical thread, resolves their origina app-server, and starts one permission-gated turn for a selected finding or bounded batch. The `review_action` completed item correlates that action with its turn; clients never send a writable replacement payload or write directly. + +`review/revert` accepts the id of a completed Apply action and starts another canonical turn whose +tool ceiling contains only `RestoreReviewAction`. The core restore tool compares every current file +with the Apply turn's exact post-image before restoring any pre-image, refusing the whole action on +conflict, path escape, snapshot corruption, Bash-only mutation, or incomplete legacy snapshots. diff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts index bc7b4d9..8f51d90 100644 --- a/packages/protocol/src/codec.test.ts +++ b/packages/protocol/src/codec.test.ts @@ -40,6 +40,7 @@ describe('protocol codec', () => { 'diagnostics/export', 'workspace/diff', 'review/apply', + 'review/revert', ] as const)('accepts the interactive response method %s', (method) => { expect(decodeProtocolRequest(JSON.stringify({ id: 2, method, params: {} }))).toEqual({ id: 2, diff --git a/packages/protocol/src/codec.ts b/packages/protocol/src/codec.ts index 22038fe..b838851 100644 --- a/packages/protocol/src/codec.ts +++ b/packages/protocol/src/codec.ts @@ -11,6 +11,7 @@ const protocolMethods = new Set([ 'diagnostics/export', 'workspace/diff', 'review/apply', + 'review/revert', 'thread/start', 'thread/read', 'thread/resume', diff --git a/packages/protocol/src/review.test.ts b/packages/protocol/src/review.test.ts index 53c1857..c19f084 100644 --- a/packages/protocol/src/review.test.ts +++ b/packages/protocol/src/review.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { reviewApplyManyPrompt, reviewApplyPrompt } from './review.js'; +import { reviewApplyManyPrompt, reviewApplyPrompt, reviewRevertPrompt } from './review.js'; describe('reviewApplyPrompt', () => { it('creates a verification-first turn prompt with the exact finding identity', () => { @@ -61,4 +61,11 @@ describe('reviewApplyPrompt', () => { 'valid review finding', ); }); + + it('builds a tool-constrained conflict-safe revert prompt', () => { + const prompt = reviewRevertPrompt('turn-apply', ['finding-1']); + expect(prompt).toContain('RestoreReviewAction exactly once'); + expect(prompt).toContain('Do not use Edit, Write, Bash'); + expect(() => reviewRevertPrompt('../unsafe', ['finding-1'])).toThrow('valid review action'); + }); }); diff --git a/packages/protocol/src/review.ts b/packages/protocol/src/review.ts index cd790ae..a5245e5 100644 --- a/packages/protocol/src/review.ts +++ b/packages/protocol/src/review.ts @@ -8,6 +8,25 @@ export function reviewApplyPrompt(finding: ReviewFindingPayload): string { return reviewApplyManyPrompt([finding]); } +/** Build a turn that can only perform the conflict-safe snapshot restore tool. */ +export function reviewRevertPrompt(actionTurnId: string, findingIds: string[]): string { + if ( + !/^[a-zA-Z0-9._-]{1,200}$/.test(actionTurnId) || + !Array.isArray(findingIds) || + findingIds.length === 0 || + findingIds.length > 20 || + findingIds.some((findingId) => !/^[a-zA-Z0-9._-]{1,200}$/.test(findingId)) + ) { + throw new Error('A valid review action is required'); + } + return ( + `Revert review action ${actionTurnId}, which applied findings: ${findingIds.join(', ')}.\n\n` + + `Call RestoreReviewAction exactly once with action_turn_id=${JSON.stringify(actionTurnId)}. ` + + 'Do not use Edit, Write, Bash, or any other mutation tool. If the restore tool reports a ' + + 'conflict or unavailable snapshot, do not work around it; explain that the action could not be safely reverted.' + ); +} + /** Build one canonical, permission-gated turn for a bounded set of findings. */ export function reviewApplyManyPrompt(findings: ReviewFindingPayload[]): string { if (!Array.isArray(findings) || findings.length === 0 || findings.length > 20) { diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index ca9d1d7..b4c72bd 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -215,11 +215,11 @@ export interface ReviewFindingPayload { replacement?: string; } -export interface ReviewActionPayload { - actionId: string; - kind: 'apply'; - findingIds: string[]; -} +export type ReviewActionRequest = + | { kind: 'apply'; findingIds: string[] } + | { kind: 'revert'; sourceActionId: string; findingIds: string[] }; + +export type ReviewActionPayload = ReviewActionRequest & { actionId: string }; export type ProtocolMethod = | 'initialize' @@ -227,6 +227,7 @@ export type ProtocolMethod = | 'diagnostics/export' | 'workspace/diff' | 'review/apply' + | 'review/revert' | 'thread/start' | 'thread/read' | 'thread/resume' From 654829f9c4abeebdd3c1494970684b1fcdb99f8c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 17:36:30 +0800 Subject: [PATCH 32/33] refactor: remove legacy desktop runtime facades --- apps/desktop/README.md | 3 +- apps/desktop/src-tauri/Cargo.lock | 1 - apps/desktop/src-tauri/Cargo.toml | 1 - apps/desktop/src-tauri/src/commands.rs | 2 +- apps/desktop/src-tauri/src/file_preview.rs | 130 ++++ apps/desktop/src-tauri/src/lib.rs | 10 +- apps/desktop/src-tauri/src/snapshots.rs | 249 +++---- apps/desktop/src-tauri/src/tools.rs | 685 ------------------- apps/desktop/src-tauri/src/voice.rs | 9 +- apps/desktop/src/App.tsx | 12 - apps/desktop/src/lib/tauri-api.test.ts | 2 +- apps/desktop/src/lib/use-file-panel.ts | 5 +- apps/desktop/src/types/screens.ts | 1 - docs/CODEX_ALIGNMENT_PLAN.md | 9 +- docs/HANDOFF.md | 2 +- packages/core/README.md | 11 +- packages/core/src/index.ts | 13 - packages/core/src/ipc/protocol.test.ts | 24 - packages/core/src/ipc/protocol.ts | 160 ----- packages/core/src/providers/deepseek.ts | 8 - packages/core/src/sessions/snapshots.test.ts | 2 + packages/core/src/sessions/snapshots.ts | 13 +- 22 files changed, 256 insertions(+), 1096 deletions(-) create mode 100644 apps/desktop/src-tauri/src/file_preview.rs delete mode 100644 apps/desktop/src-tauri/src/tools.rs delete mode 100644 packages/core/src/ipc/protocol.test.ts delete mode 100644 packages/core/src/ipc/protocol.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index acef184..95a865a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -22,7 +22,8 @@ src-tauri/ Rust 主进程 src/commands.rs #[tauri::command] —— renderer 通过 invoke() 调用 src/credentials.rs 凭据保存与无密钥状态查询 src/settings.rs 设置持久化 - src/tools.rs legacy native helpers(renderer 仅暴露只读 file read) + src/file_preview.rs 只读文件预览(不包含工作区变更能力) + src/snapshots.rs app-server snapshot 的只读 Diff/History 投影 src/lib.rs Tauri builder / 插件注册 tauri.conf.json 窗口 + 构建 + 打包配置 capabilities/ 权限能力声明 diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index ce392f9..7eaaeed 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -678,7 +678,6 @@ dependencies = [ "libc", "serde", "serde_json", - "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7a0e4f0..4bb7c97 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,7 +23,6 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = "0.10" thiserror = "1" tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "sync", "time", "process"] } dirs = "5" diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 728f82b..2d91855 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -721,7 +721,7 @@ pub fn list_plugins() -> Vec { // ── Serde contract ───────────────────────────────────────────────────── // AppInfo + SessionMeta are read by tauri-api.ts using snake_case keys // (home_dir, size_bytes, updated_at_secs). They intentionally do NOT use -// rename_all="camelCase" (unlike the tool output structs in tools.rs). Lock +// rename_all="camelCase" (unlike the read-only file preview response). Lock // that so a stray rename_all can't silently break the renderer. See HANDOFF §8a. #[cfg(test)] mod contract_tests { diff --git a/apps/desktop/src-tauri/src/file_preview.rs b/apps/desktop/src-tauri/src/file_preview.rs new file mode 100644 index 0000000..55dd288 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_preview.rs @@ -0,0 +1,130 @@ +// Read-only file preview exposed to the desktop renderer. Runtime mutations +// belong to the bundled app-server and are intentionally absent from Tauri's +// command surface. + +use serde::Serialize; +use std::path::Path; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadOk { + pub content: String, + pub lines_total: usize, + pub lines_shown: usize, + pub offset: usize, +} + +#[tauri::command] +pub async fn tool_read( + file_path: String, + offset: Option, + limit: Option, +) -> Result { + 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(); + let offset = offset.unwrap_or(1).max(1); + let limit = limit.unwrap_or(2000).max(1); + let start = (offset - 1).min(lines.len()); + let end = (start + limit).min(lines.len()); + let slice = &lines[start..end]; + + let numbered: Vec = slice + .iter() + .enumerate() + .map(|(i, line)| { + let n = offset + i; + let truncated = if line.chars().count() > 2000 { + format!("{}... [truncated]", line.chars().take(2000).collect::()) + } else { + line.to_string() + }; + format!("{:>6}\t{}", n, truncated) + }) + .collect(); + let mut content = numbered.join("\n"); + let shown = slice.len(); + let total = lines.len(); + if shown < total.saturating_sub(start) { + content.push_str(&format!( + "\n\n[Showing lines {}-{} of {}. Use offset/limit to see more.]", + offset, + offset + shown - 1, + total + )); + } + Ok(ReadOk { + content, + lines_total: total, + lines_shown: shown, + offset, + }) +} + +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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_ok_serializes_camel_case() { + let value = serde_json::to_value(ReadOk { + content: String::new(), + lines_total: 10, + lines_shown: 5, + offset: 1, + }) + .unwrap(); + let object = value.as_object().unwrap(); + assert!(object.contains_key("linesTotal")); + assert!(object.contains_key("linesShown")); + assert!(!object.contains_key("lines_total")); + } + + #[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()); + } + + #[tokio::test] + async fn read_handles_unicode_truncation_and_offset_past_eof() { + let path = std::env::temp_dir().join(format!( + "deepcode-preview-{}-{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, "界".repeat(2001)).unwrap(); + let preview = tool_read(path.to_string_lossy().into_owned(), None, None) + .await + .unwrap(); + assert!(preview.content.ends_with("... [truncated]")); + let empty = tool_read(path.to_string_lossy().into_owned(), Some(99), None) + .await + .unwrap(); + assert_eq!(empty.lines_shown, 0); + assert!(empty.content.is_empty()); + std::fs::remove_file(path).ok(); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 25bc2a4..b2b4eae 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -10,25 +10,23 @@ mod app_server; mod commands; mod credentials; +mod file_preview; 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; 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, - credential_status, list_skills, load_keybindings, load_settings_file, open_url, + append_allow_matcher, cli_path, credential_status, get_app_info, get_settings_path, + list_plugins, list_sessions, 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 file_preview::tool_read; use snapshots::session_snapshots; use tauri::Manager; -use tools::tool_read; use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/apps/desktop/src-tauri/src/snapshots.rs b/apps/desktop/src-tauri/src/snapshots.rs index a97ccbe..be0e5c8 100644 --- a/apps/desktop/src-tauri/src/snapshots.rs +++ b/apps/desktop/src-tauri/src/snapshots.rs @@ -1,103 +1,18 @@ -// File snapshots — captured before & after each Edit/Write so the right-side -// file panel's Diff/History tabs (and the CLI's `/rewind`) share one data source. -// -// The desktop runs @deepcode/core's `runAgent` IN THE RENDERER, which (by design) -// has no node:fs and so passes no SessionManager — meaning core's own snapshot -// capture (packages/core/src/agent.ts) never fires for desktop sessions. We -// therefore mirror it here on the Rust side: tool_write / tool_edit call -// `capture_file_snapshot` for the pre- and post-mutation states. -// -// On-disk layout MATCHES core (packages/core/src/sessions/{storage,snapshots}.ts) -// so the two interoperate: -// ~/.deepcode/sessions//snapshots/ -// manifest.jsonl — one JSON Snapshot per line -// --.blob — the captured file bytes -// -// Each manifest line is the core `Snapshot` shape: { filePath, capturedAt, -// reason, hash, size, seq, blobPath, kind } plus a `capturedAtMs` convenience -// field (core ignores unknown keys) so the renderer needn't parse ISO strings. +// Read-only projection of app-server-owned file snapshots for the desktop +// Diff/History panel. Snapshot capture and every workspace mutation happen in +// @deepcode/core behind the versioned app-server protocol. use serde::Serialize; -use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; /// `~/.deepcode/sessions//snapshots` — the per-session snapshot directory. -pub fn snapshots_dir(home: &Path, session_id: &str) -> PathBuf { +fn snapshots_dir(home: &Path, session_id: &str) -> PathBuf { home.join(".deepcode") .join("sessions") .join(session_id) .join("snapshots") } -/// Next sequence number for a session = count of existing manifest lines. -/// Snapshots are append-only and the desktop captures them one tool-call at a -/// time, so a line count is a sufficient monotonic counter (mirrors core's -/// per-session `snapshotSeq`). -pub fn next_seq(dir: &Path) -> u64 { - let manifest = dir.join("manifest.jsonl"); - match std::fs::read_to_string(&manifest) { - Ok(t) => t.lines().filter(|l| !l.trim().is_empty()).count() as u64, - Err(_) => 0, - } -} - -/// Milliseconds since the Unix epoch (0 if the clock is before 1970). -pub fn now_ms() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) -} - -/// Capture one file snapshot: write the blob and append a manifest line. -/// Best-effort by contract — callers ignore the error so a snapshot hiccup never -/// fails the user's edit. `content` is the exact file bytes for this revision. -pub fn capture_file_snapshot( - home: &Path, - session_id: &str, - file_path: &str, - content: &[u8], - reason: &str, - seq: u64, - captured_ms: u128, -) -> std::io::Result<()> { - let dir = snapshots_dir(home, session_id); - std::fs::create_dir_all(&dir)?; - - let mut hasher = Sha256::new(); - hasher.update(content); - // core: sha256 hex truncated to 16 chars == the first 8 bytes. - let hash16: String = hasher - .finalize() - .iter() - .take(8) - .map(|b| format!("{b:02x}")) - .collect(); - - let blob_name = format!("{:05}-{}-{}.blob", seq, fmt_blob_ts(captured_ms), hash16); - let blob_path = dir.join(&blob_name); - std::fs::write(&blob_path, content)?; - - let entry = serde_json::json!({ - "filePath": file_path, - "capturedAt": fmt_iso(captured_ms), - "capturedAtMs": captured_ms as u64, - "reason": reason, - "hash": hash16, - "size": content.len(), - "seq": seq, - "blobPath": blob_path.to_string_lossy(), - "kind": "file", - }); - - use std::io::Write; - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(dir.join("manifest.jsonl"))?; - writeln!(f, "{entry}") -} - // ── session_snapshots command ─────────────────────────────────────────────── /// One snapshot returned to the renderer for a single file. `content` is the @@ -160,9 +75,18 @@ pub fn list_file_snapshots(dir: &Path, file_path: &str) -> Result) -> false } -// ── time formatting (no chrono dep) ───────────────────────────────────────── - -/// (year, month, day) from days-since-Unix-epoch. Howard Hinnant's -/// civil_from_days — same algorithm as commands.rs::format_date. -fn civil_from_days(days: i64) -> (i64, u64, u64) { - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; // [0, 146096] - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d) -} - -/// ISO-8601 UTC with millis, e.g. "2026-06-04T12:30:45.123Z" (mirrors JS -/// `new Date(ms).toISOString()`). -fn fmt_iso(ms: u128) -> String { - let total_secs = (ms / 1000) as i64; - let millis = (ms % 1000) as u64; - let days = total_secs.div_euclid(86_400); - let tod = total_secs.rem_euclid(86_400) as u64; - let (y, mo, d) = civil_from_days(days); - let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60); - format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z") -} - -/// Compact timestamp for blob filenames: core's -/// `toISOString().replace(/[-:.]/g,'').slice(0,15)` → "YYYYMMDDtHHMMSS". -fn fmt_blob_ts(ms: u128) -> String { - fmt_iso(ms) - .chars() - .filter(|c| *c != '-' && *c != ':' && *c != '.') - .take(15) - .collect() +/// Parse core's fixed-width UTC ISO timestamp without adding a date-time +/// dependency to the desktop binary. +fn parse_iso_millis(value: &str) -> Option { + if value.len() != 24 + || !value.is_ascii() + || &value[4..5] != "-" + || &value[7..8] != "-" + || &value[10..11] != "T" + || &value[13..14] != ":" + || &value[16..17] != ":" + || &value[19..20] != "." + || &value[23..24] != "Z" + { + return None; + } + let year = value[0..4].parse::().ok()?; + let month = value[5..7].parse::().ok()?; + let day = value[8..10].parse::().ok()?; + let hour = value[11..13].parse::().ok()?; + let minute = value[14..16].parse::().ok()?; + let second = value[17..19].parse::().ok()?; + let millis = value[20..23].parse::().ok()?; + if !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&minute) + || !(0..=59).contains(&second) + { + return None; + } + let adjusted_year = year - i64::from(month <= 2); + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let shifted_month = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + let days = era * 146_097 + day_of_era - 719_468; + let total = (((days * 24 + hour) * 60 + minute) * 60 + second) * 1000 + millis; + u64::try_from(total).ok() } #[cfg(test)] @@ -243,19 +168,13 @@ mod tests { } #[test] - fn fmt_iso_known_values() { - assert_eq!(fmt_iso(0), "1970-01-01T00:00:00.000Z"); - assert_eq!(fmt_iso(86_400_000), "1970-01-02T00:00:00.000Z"); - // 2023-11-14T22:13:20.123Z - assert_eq!(fmt_iso(1_700_000_000_123), "2023-11-14T22:13:20.123Z"); - } - - #[test] - fn fmt_blob_ts_is_15_chars_with_t_separator() { - let ts = fmt_blob_ts(0); - assert_eq!(ts, "19700101T000000"); - assert_eq!(ts.chars().count(), 15); - assert_eq!(ts.as_bytes()[8], b'T'); + fn parses_core_iso_timestamp() { + assert_eq!(parse_iso_millis("1970-01-01T00:00:00.000Z"), Some(0)); + assert_eq!( + parse_iso_millis("2023-11-14T22:13:20.123Z"), + Some(1_700_000_000_123) + ); + assert_eq!(parse_iso_millis("not-a-timestamp"), None); } #[test] @@ -277,37 +196,37 @@ mod tests { } #[test] - fn capture_then_list_roundtrips_and_filters() { - let home = std::env::temp_dir().join(format!("dc-snap-{}", std::process::id())); - let sid = "2026-06-04-test01"; + fn list_reads_core_manifest_and_filters() { + let root = std::env::temp_dir().join(format!("dc-snap-{}", std::process::id())); let file = "/tmp/example/app.ts"; - let _ = std::fs::remove_dir_all(&home); - - // Two edits → 4 snapshots (pre/post each), distinct ms so ordering holds. - let dir = snapshots_dir(&home, sid); + let other = "/tmp/other.ts"; + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("snapshots"); std::fs::create_dir_all(&dir).unwrap(); - let base0 = next_seq(&dir); - capture_file_snapshot(&home, sid, file, b"v0\n", "pre-Edit", base0, 1000).unwrap(); - capture_file_snapshot(&home, sid, file, b"v1\n", "post-Edit", base0 + 1, 1001).unwrap(); - let base1 = next_seq(&dir); - assert_eq!(base1, 2, "seq advances with manifest lines"); - capture_file_snapshot(&home, sid, file, b"v1\n", "pre-Edit", base1, 2000).unwrap(); - capture_file_snapshot(&home, sid, file, b"v2\n", "post-Edit", base1 + 1, 2001).unwrap(); - - // A snapshot for a DIFFERENT file must be filtered out. - capture_file_snapshot(&home, sid, "/tmp/other.ts", b"z\n", "pre-Write", 99, 3000).unwrap(); + let first_blob = dir.join("first.blob"); + let second_blob = dir.join("second.blob"); + std::fs::write(&first_blob, "v0\n").unwrap(); + std::fs::write(&second_blob, "v1\n").unwrap(); + let manifest = [ + serde_json::json!({"filePath": file, "capturedAt": "2023-11-14T22:13:20.123Z", "reason": "pre-Edit", "hash": "a", "seq": 2, "blobPath": first_blob}), + serde_json::json!({"filePath": file, "capturedAtMs": 1_700_000_000_124_u64, "reason": "post-Edit", "hash": "b", "seq": 3, "blobPath": second_blob}), + serde_json::json!({"filePath": other, "capturedAtMs": 1_u64, "reason": "pre-Write", "hash": "c", "seq": 1, "blobPath": ""}), + ] + .into_iter() + .map(|row| row.to_string()) + .collect::>() + .join("\n"); + std::fs::write(dir.join("manifest.jsonl"), manifest).unwrap(); let rows = list_file_snapshots(&dir, file).unwrap(); - let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&root); - assert_eq!(rows.len(), 4, "only the 4 snapshots for `file`"); - assert_eq!(rows[0].seq, 0); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].seq, 2); assert_eq!(rows[0].content, "v0\n"); assert_eq!(rows[0].reason, "pre-Edit"); - assert_eq!(rows[0].captured_at_ms, 1000); - assert_eq!(rows[3].content, "v2\n"); - // ascending by seq - assert!(rows.windows(2).all(|w| w[0].seq < w[1].seq)); + assert_eq!(rows[0].captured_at_ms, 1_700_000_000_123); + assert_eq!(rows[1].captured_at_ms, 1_700_000_000_124); } #[test] diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs deleted file mode 100644 index 016ff35..0000000 --- a/apps/desktop/src-tauri/src/tools.rs +++ /dev/null @@ -1,685 +0,0 @@ -// Tool IO primitives exposed to the renderer. -// The renderer runs @deepcode/core's `runAgent` directly; its tools call -// these Tauri commands for actual fs / subprocess work (the webview can't -// do node:fs / node:child_process itself). - -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 -// ────────────────────────────────────────────────────────────────────────── -// Edit/Write record a pre- and post-mutation snapshot so the desktop file -// panel's Diff/History tabs (and `/rewind`) have data — mirroring core's -// agent.ts, which never runs for desktop sessions (no SessionManager in the -// renderer). Best-effort: capture failures are logged and ignored so a -// snapshot hiccup never fails the user's edit. - -/// Capture the pre + post pair for one file mutation under the user's home dir. -fn capture_pair(session_id: &str, file_path: &str, pre: &[u8], post: &[u8], tool: &str) { - let Some(home) = dirs::home_dir() else { - return; - }; - capture_pair_in(&home, session_id, file_path, pre, post, tool); -} - -/// home-parameterized body of `capture_pair` (testable without the real home). -/// `pre`/`post` are the file bytes before/after the change. The post snapshot is -/// stamped 1ms after the pre so the two never collide on a millisecond timeline -/// (the renderer keys history entries by timestamp). -fn capture_pair_in( - home: &Path, - session_id: &str, - file_path: &str, - pre: &[u8], - post: &[u8], - tool: &str, -) { - let dir = snapshots::snapshots_dir(home, session_id); - let base = snapshots::next_seq(&dir); - let t = snapshots::now_ms(); - if let Err(e) = snapshots::capture_file_snapshot( - home, - session_id, - file_path, - pre, - &format!("pre-{tool}"), - base, - t, - ) { - eprintln!("snapshot pre-{tool} {file_path}: {e}"); - } - if let Err(e) = snapshots::capture_file_snapshot( - home, - session_id, - file_path, - post, - &format!("post-{tool}"), - base + 1, - t + 1, - ) { - eprintln!("snapshot post-{tool} {file_path}: {e}"); - } -} - -// ────────────────────────────────────────────────────────────────────────── -// Read -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadOk { - pub content: String, - pub lines_total: usize, - pub lines_shown: usize, - pub offset: usize, -} - -#[tauri::command] -pub async fn tool_read( - file_path: String, - offset: Option, - limit: Option, -) -> Result { - 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(); - let offset = offset.unwrap_or(1).max(1); - let limit = limit.unwrap_or(2000).max(1); - let start = offset - 1; - let end = (start + limit).min(lines.len()); - let slice = &lines[start..end]; - - let numbered: Vec = slice - .iter() - .enumerate() - .map(|(i, line)| { - let n = offset + i; - let truncated = if line.len() > 2000 { - format!("{}... [truncated]", &line[..2000]) - } else { - line.to_string() - }; - format!("{:>6}\t{}", n, truncated) - }) - .collect(); - let mut content = numbered.join("\n"); - let shown = slice.len(); - let total = lines.len(); - if shown < total.saturating_sub(start) { - content.push_str(&format!( - "\n\n[Showing lines {}-{} of {}. Use offset/limit to see more.]", - offset, - offset + shown - 1, - total - )); - } - Ok(ReadOk { - content, - lines_total: total, - lines_shown: shown, - offset, - }) -} - -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 -// ────────────────────────────────────────────────────────────────────────── - -#[tauri::command] -pub async fn tool_write( - file_path: String, - content: String, - session_id: Option, -) -> Result<(), String> { - // Pre-state: the existing file bytes (empty when the file is new) — read - // before the overwrite so the post-Write diff has a baseline. - let pre = tokio::fs::read(&file_path).await.unwrap_or_default(); - if let Some(parent) = Path::new(&file_path).parent() { - if !parent.as_os_str().is_empty() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?; - } - } - tokio::fs::write(&file_path, &content) - .await - .map_err(|e| format!("write {}: {}", file_path, e))?; - if let Some(sid) = session_id.as_deref() { - capture_pair(sid, &file_path, &pre, content.as_bytes(), "Write"); - } - Ok(()) -} - -// ────────────────────────────────────────────────────────────────────────── -// Edit -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "snake_case")] -pub struct EditInput { - pub file_path: String, - pub old_string: String, - pub new_string: String, - pub replace_all: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EditOk { - pub replaced: usize, - pub diff_preview: String, -} - -#[tauri::command] -pub async fn tool_edit(input: EditInput, session_id: Option) -> Result { - let raw = tokio::fs::read_to_string(&input.file_path) - .await - .map_err(|e| format!("read {}: {}", input.file_path, e))?; - let replace_all = input.replace_all.unwrap_or(false); - let (new_content, count) = if replace_all { - let count = raw.matches(&input.old_string).count(); - (raw.replace(&input.old_string, &input.new_string), count) - } else { - // Uniqueness check (matching the CLI's Edit tool behavior) - let count = raw.matches(&input.old_string).count(); - if count == 0 { - return Err("old_string not found in file".into()); - } - if count > 1 { - return Err(format!( - "old_string is not unique (found {count} occurrences). Use replace_all=true or provide more context." - )); - } - (raw.replacen(&input.old_string, &input.new_string, 1), 1) - }; - tokio::fs::write(&input.file_path, &new_content) - .await - .map_err(|e| format!("write {}: {}", input.file_path, e))?; - if let Some(sid) = session_id.as_deref() { - capture_pair( - sid, - &input.file_path, - raw.as_bytes(), - new_content.as_bytes(), - "Edit", - ); - } - let diff_preview = format!( - "- {}\n+ {}", - input.old_string.lines().next().unwrap_or(""), - input.new_string.lines().next().unwrap_or("") - ); - Ok(EditOk { - replaced: count, - diff_preview, - }) -} - -// ────────────────────────────────────────────────────────────────────────── -// Bash -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "snake_case")] -pub struct BashInput { - pub command: String, - pub cwd: Option, - pub timeout_ms: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BashOk { - pub stdout: String, - 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, - 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); - if let Some(cwd) = input.cwd.as_ref() { - 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")?; - - // Read both streams concurrently - let stdout_task = tokio::spawn(async move { - let mut s = String::new(); - let _ = stdout_pipe.read_to_string(&mut s).await; - s - }); - let stderr_task = tokio::spawn(async move { - let mut s = String::new(); - let _ = stderr_pipe.read_to_string(&mut s).await; - s - }); - - 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; - (130, false, true) - } - }; - let stdout = stdout_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, - 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) -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct GlobOk { - pub files: Vec, - pub truncated: bool, -} - -#[tauri::command] -pub async fn tool_glob(pattern: String, cwd: Option) -> Result { - // Walk + filter using the `walkdir` style approach via shell `find -path`. - // We don't depend on the `globwalk` crate to keep deps slim; shell out instead. - let cwd_path = cwd.unwrap_or_else(|| ".".into()); - // For safety, only run if pattern doesn't contain a quote injection - if pattern.contains('\'') || pattern.contains('`') { - return Err("unsafe pattern (contains quote)".into()); - } - let script = format!( - "find {} -type f -path '{}/{}' 2>/dev/null | head -1000", - shell_escape(&cwd_path), - shell_escape(&cwd_path), - pattern - ); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(&script) - .output() - .await - .map_err(|e| format!("spawn find: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout); - let files: Vec = stdout.lines().map(|s| s.to_string()).collect(); - let truncated = files.len() >= 1000; - Ok(GlobOk { files, truncated }) -} - -fn shell_escape(s: &str) -> String { - // Minimal escape — wrap in single quotes, escape any existing single quotes - format!("'{}'", s.replace('\'', "'\\''")) -} - -// ────────────────────────────────────────────────────────────────────────── -// Grep (ripgrep-like; uses /usr/bin/grep) -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "snake_case")] -pub struct GrepInput { - pub pattern: String, - pub path: Option, - pub include: Option, - pub case_insensitive: Option, -} - -#[derive(Serialize)] -pub struct GrepOk { - pub matches: Vec, - pub truncated: bool, -} - -#[derive(Serialize)] -pub struct GrepMatch { - pub file: String, - pub line: usize, - pub text: String, -} - -#[tauri::command] -pub async fn tool_grep(input: GrepInput) -> Result { - let path = input.path.unwrap_or_else(|| ".".into()); - let mut cmd = Command::new("/usr/bin/grep"); - cmd.arg("-rn"); - if input.case_insensitive.unwrap_or(false) { - cmd.arg("-i"); - } - if let Some(include) = input.include.as_ref() { - cmd.arg(format!("--include={include}")); - } - cmd.arg("--").arg(&input.pattern).arg(&path); - let output = cmd.output().await.map_err(|e| format!("spawn grep: {e}"))?; - // grep returns 1 if no matches — that's not an error for us - if !output.status.success() && output.status.code() != Some(1) { - return Err(format!( - "grep failed ({}): {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let mut matches = Vec::new(); - for line in stdout.lines().take(500) { - // format: :: - let mut parts = line.splitn(3, ':'); - let file = parts.next().unwrap_or("").to_string(); - let lineno: usize = parts.next().unwrap_or("0").parse().unwrap_or(0); - let text = parts.next().unwrap_or("").to_string(); - matches.push(GrepMatch { - file, - line: lineno, - text, - }); - } - let truncated = matches.len() == 500; - Ok(GrepOk { matches, truncated }) -} - -// ── Serde casing contract ────────────────────────────────────────────── -// Regression guard for HANDOFF §8a: Tauri's serde does NOT auto-convert case -// between Rust and JS. Every multi-word *output* field must serialize as -// camelCase, because the renderer reads e.g. `r.exitCode` / `r.linesTotal`. -// This bug shipped twice (Read line counts, Bash exit-code "error" badge); these -// tests fail loudly if a `#[serde(rename_all = "camelCase")]` is ever dropped. -#[cfg(test)] -mod casing_tests { - use super::*; - - fn keys(v: &serde_json::Value) -> Vec { - v.as_object().unwrap().keys().cloned().collect() - } - - #[test] - fn read_ok_serializes_camel_case() { - let v = serde_json::to_value(ReadOk { - content: String::new(), - lines_total: 10, - lines_shown: 5, - offset: 0, - }) - .unwrap(); - let k = keys(&v); - assert!(k.contains(&"linesTotal".to_string()), "got {k:?}"); - assert!(k.contains(&"linesShown".to_string()), "got {k:?}"); - assert!( - !k.contains(&"lines_total".to_string()), - "snake_case leaked: {k:?}" - ); - } - - #[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 { - replaced: 1, - diff_preview: String::new(), - }) - .unwrap(); - let k = keys(&v); - assert!(k.contains(&"diffPreview".to_string()), "got {k:?}"); - assert!( - !k.contains(&"diff_preview".to_string()), - "snake_case leaked: {k:?}" - ); - } - - #[test] - fn bash_ok_serializes_camel_case() { - let v = serde_json::to_value(BashOk { - stdout: String::new(), - 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 ─────────────────────────────────────────────── -// End-to-end coverage of the Edit/Write → manifest path (against a real temp -// fs) via the home-injectable `capture_pair_in`. -#[cfg(test)] -mod snapshot_capture_tests { - use super::*; - - #[test] - fn capture_pair_writes_pre_then_post_with_distinct_ms() { - let home = std::env::temp_dir().join(format!("dc-cap-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&home); - let sid = "2026-06-04-cap01"; - let file = "/tmp/example/app.ts"; - - capture_pair_in(&home, sid, file, b"old\n", b"new\n", "Edit"); - - let dir = snapshots::snapshots_dir(&home, sid); - let rows = snapshots::list_file_snapshots(&dir, file).unwrap(); - let _ = std::fs::remove_dir_all(&home); - - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].reason, "pre-Edit"); - assert_eq!(rows[0].content, "old\n"); - assert_eq!(rows[0].seq, 0); - assert_eq!(rows[1].reason, "post-Edit"); - assert_eq!(rows[1].content, "new\n"); - assert_eq!(rows[1].seq, 1); - // Distinct timestamps so the renderer's history keys never collide. - assert_eq!(rows[1].captured_at_ms, rows[0].captured_at_ms + 1); - } - - #[test] - fn capture_pair_appends_across_calls_with_monotonic_seq() { - let home = std::env::temp_dir().join(format!("dc-cap2-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&home); - let sid = "2026-06-04-cap02"; - let file = "/tmp/x.ts"; - - capture_pair_in(&home, sid, file, b"a", b"b", "Write"); - capture_pair_in(&home, sid, file, b"b", b"c", "Edit"); - - let dir = snapshots::snapshots_dir(&home, sid); - let rows = snapshots::list_file_snapshots(&dir, file).unwrap(); - let _ = std::fs::remove_dir_all(&home); - - assert_eq!( - rows.iter().map(|r| r.seq).collect::>(), - vec![0, 1, 2, 3] - ); - assert_eq!(rows[0].reason, "pre-Write"); - assert_eq!(rows[3].reason, "post-Edit"); - assert_eq!(rows[3].content, "c"); - } -} diff --git a/apps/desktop/src-tauri/src/voice.rs b/apps/desktop/src-tauri/src/voice.rs index d05474b..82d7e53 100644 --- a/apps/desktop/src-tauri/src/voice.rs +++ b/apps/desktop/src-tauri/src/voice.rs @@ -246,7 +246,7 @@ pub async fn voice_start(state: tauri::State<'_, VoiceState>) -> Result<(), Stri let wav = std::env::temp_dir().join(format!( "deepcode-voice-{}-{}.wav", std::process::id(), - crate::snapshots::now_ms() + unix_time_millis() )); // Replace any orphaned prior recording. @@ -278,6 +278,13 @@ pub async fn voice_start(state: tauri::State<'_, VoiceState>) -> Result<(), Stri Ok(()) } +fn unix_time_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + /// Stop recording, transcribe the clip, delete the audio, return the text. #[tauri::command] pub async fn voice_stop(state: tauri::State<'_, VoiceState>) -> Result { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index ff58361..8cea3ea 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -307,18 +307,6 @@ function renderScreen( onOpenFile?: (path: string) => void, ): JSX.Element { switch (screen) { - case 'chat': - // 'chat' folded into 'repl' — the new shell has only the REPL surface. - return ( - - ); case 'sessions': return setScreen('repl')} onNew={() => setScreen('repl')} />; // Settings-family screens share the Settings shell's left nav so they're diff --git a/apps/desktop/src/lib/tauri-api.test.ts b/apps/desktop/src/lib/tauri-api.test.ts index 6e9b63d..f4c969d 100644 --- a/apps/desktop/src/lib/tauri-api.test.ts +++ b/apps/desktop/src/lib/tauri-api.test.ts @@ -3,7 +3,7 @@ // These lock the command names and the snake_case↔camelCase mapping that the // Rust #[tauri::command] handlers expect. HANDOFF §8a: casing mismatches across // this boundary shipped real bugs twice. The Rust side is guarded by -// src-tauri/src/tools.rs casing_tests; this guards the TS side. +// src-tauri/src/file_preview.rs tests; this guards the TS side. // // `invoke` is mocked so no Tauri runtime is needed. diff --git a/apps/desktop/src/lib/use-file-panel.ts b/apps/desktop/src/lib/use-file-panel.ts index ae1f7e2..f6d8c09 100644 --- a/apps/desktop/src/lib/use-file-panel.ts +++ b/apps/desktop/src/lib/use-file-panel.ts @@ -4,8 +4,9 @@ // split/inline toggle is owned by App (it shares the chord with the inspector // toggle and resolves contextually). // -// Diff/History come from session snapshots captured on the Rust side for every -// Edit/Write (see src-tauri/src/snapshots.rs). On open() we fetch a file's +// Diff/History come from session snapshots captured by the app-server for every +// Edit/Write. Rust exposes only a read-only projection (src-tauri/src/snapshots.rs). +// On open() we fetch a file's // snapshots and derive: the History timeline, and a Diff of the current file // vs the session baseline (its oldest snapshot). Selecting a History entry // recomputes the Diff against that revision. diff --git a/apps/desktop/src/types/screens.ts b/apps/desktop/src/types/screens.ts index ed22fd1..c73e635 100644 --- a/apps/desktop/src/types/screens.ts +++ b/apps/desktop/src/types/screens.ts @@ -4,7 +4,6 @@ export type ScreenName = | 'repl' - | 'chat' // alias for 'repl' — kept for IPC-shim backwards compat | 'sessions' | 'plugins' | 'skills' diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 4b15c3a..a04beef 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -344,9 +344,12 @@ model tool call Bash checkpoint 或 legacy 不完整快照时整体拒绝。恢复仍经过 permission/approval/hooks,且 自身产生 pre/post 快照;新建文件可按 `existed=false` 安全删除。 - 在 worktree 语义安全后启用隔离写任务;sub-agent 深度维持安全上限,按真实需求扩展 agent graph。 -- 客户端内联评论/上下文 action;VS Code review all 与 latest-action revert 已由同一 - canonical action path 提供,Desktop/LSP 暴露对应协议能力。 -- 删除完成迁移的旧 IPC/facade;更新所有用户文档。 +- VS Code review all 与 latest-action revert 已由同一 canonical action path 提供,Desktop/LSP + 暴露对应协议能力;客户端内联评论/上下文 action 在有真实使用证据前继续后置,避免复制第二套 + finding/action 状态。 +- 已删除无生产引用的 core renderer IPC、desktop native mutation/tool facade 与 `chat` 路由别名; + Tauri 仅保留 credential presence、文件/快照只读投影和 sidecar supervision。legacy session importer + 继续只读,旧文件不迁移、不覆盖;用户文档已改为 app-server 架构。 - release candidate、迁移演练、性能预算和回滚说明。 验收:端到端 golden journey、性能基线、安全 review、文档与 release gate。 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 5365349..2273e7c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # DeepCode — Session Handoff -> **历史快照(2026-05)**:本文件用于追溯早期实现,包含已过期的 commit、测试数和待办。当前架构与推进顺序见 [`CODEX_ALIGNMENT_PLAN.md`](CODEX_ALIGNMENT_PLAN.md),仓库操作规范见 [`../AGENTS.md`](../AGENTS.md)。 +> **历史快照(2026-05,请勿作为当前操作手册)**:本文件用于追溯早期 renderer runtime 实现,下面的 commit、测试数、文件路径、发布步骤和待办均可能已过期。当前架构与推进顺序见 [`CODEX_ALIGNMENT_PLAN.md`](CODEX_ALIGNMENT_PLAN.md),app-server 边界见 [`design/app-server-packaging-v1.md`](design/app-server-packaging-v1.md),仓库操作规范见 [`../AGENTS.md`](../AGENTS.md)。 A new Claude Code session can pick up DeepCode from this document alone. It's intentionally dense — read once top-to-bottom, then keep open as a map. diff --git a/packages/core/README.md b/packages/core/README.md index 6e24127..63eb1bc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,11 +6,12 @@ DeepCode 的 TypeScript 内核包:agent loop、DeepSeek provider、tools、con ## 当前状态 -主要模块均已有实现与测试。CLI、headless、LSP 与 VS Code 已通过 `RuntimeHost` 固定 provider、tools、permissions、hooks 与 sandbox 等安全服务;`runAgent` 保留为 core 内部循环和 desktop 迁移期兼容入口。当前剩余的主要 host 差异是 desktop renderer 仍直接运行 provider/loop,后续按 packaging ADR 迁出 WebView。 +主要模块均已有实现与测试。CLI/headless 通过 `RuntimeHost` 运行;desktop、VS Code 与 LSP 则作为版本化 app-server 协议的薄客户端。provider、凭据、agent loop、tools、permissions、hooks、sandbox、MCP 与 plugins 都由受信任的 Node host 统一组装,renderer 不运行模型或工作区变更逻辑。 关键入口: -- `src/agent.ts`:现有 agent loop 与兼容 facade。 +- `src/runtime/`:`RuntimeHost`、默认 runtime 组装与执行器。 +- `src/agent.ts`:host 内部使用的 agent loop 与兼容 facade。 - `src/providers/`:DeepSeek provider 与 capability/pricing。 - `src/tools/`:内置工具和 registry。 - `src/harness/tool-dispatcher.ts`:mode、permissions 与 hook gate。 @@ -20,8 +21,4 @@ DeepCode 的 TypeScript 内核包:agent loop、DeepSeek provider、tools、con ## API 入口 -```ts -import { runAgent, ToolRegistry, BUILTIN_TOOLS } from '@deepcode/core'; -``` - -公共 API 见 [`docs/core-api.md`](../../docs/core-api.md)。新 host 不应直接复制 CLI 的组装代码;在 `RuntimeHost` 落地前,新增入口必须显式传入 mode、permissions、trust 与 sandbox policy。 +公共 API 见 [`docs/core-api.md`](../../docs/core-api.md)。新增交互界面应实现 `@deepcode/protocol` 客户端;仅 headless/嵌入式 Node host 应直接使用 `RuntimeHost`。不要在 renderer、编辑器 extension host 或 LSP 进程中复制 provider/tool 组装。 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb78eba..ffd0acc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -355,19 +355,6 @@ export { type CreateTaskSpec, } from './tasks/manager.js'; -// IPC protocol (M6-rest — renderer ↔ main process type-safe channels) -export { - newTurnId, - newQuestionId, - type IpcChannel, - type IpcEventChannel, - type IpcRequest, - type IpcResponse, - type IpcRequestMap, - type IpcEventMap, - type AgentStreamEvent, -} from './ipc/protocol.js'; - // Voice input (M8 — whisper.cpp wrapper + stub provider + setup detection) export { WhisperCppProvider, diff --git a/packages/core/src/ipc/protocol.test.ts b/packages/core/src/ipc/protocol.test.ts deleted file mode 100644 index 2cea696..0000000 --- a/packages/core/src/ipc/protocol.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { newQuestionId, newTurnId } from './protocol.js'; - -describe('newTurnId', () => { - it('returns turn--', () => { - const id = newTurnId(); - expect(id).toMatch(/^turn-[0-9a-z]+-[0-9a-f]{24}$/); - }); - it('produces unique ids across rapid calls', () => { - const set = new Set(Array.from({ length: 50 }, newTurnId)); - expect(set.size).toBe(50); - }); -}); - -describe('newQuestionId', () => { - it('returns q--', () => { - const id = newQuestionId(); - expect(id).toMatch(/^q-[0-9a-z]+-[0-9a-f]{24}$/); - }); - it('produces unique ids', () => { - const set = new Set(Array.from({ length: 50 }, newQuestionId)); - expect(set.size).toBe(50); - }); -}); diff --git a/packages/core/src/ipc/protocol.ts b/packages/core/src/ipc/protocol.ts deleted file mode 100644 index ac5340b..0000000 --- a/packages/core/src/ipc/protocol.ts +++ /dev/null @@ -1,160 +0,0 @@ -// IPC protocol between the Electron renderer and the main process. -// Spec: docs/DEVELOPMENT_PLAN.md §4 -// -// Goals: -// 1. Type-safe channel names + payload shapes (no string-typed `ipc.invoke`). -// 2. Stream agent events (text_delta / tool_use / tool_result / usage / -// 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). -// -// Channel naming convention: `:` for request/response invokes -// and `:event` for streamed events. - -import { randomBytes } from 'node:crypto'; -import type { AgentEvent, Mode, StoredMessage } from '../types.js'; - -// ────────────────────────────────────────────────────────────────────────── -// Request/response channels (renderer → main → reply) -// ────────────────────────────────────────────────────────────────────────── - -export interface IpcRequestMap { - 'app:version': { req: void; res: string }; - 'creds:load': { req: void; res: { hasKey: boolean; baseURL?: string } }; - 'creds:save': { req: { apiKey: string; baseURL?: string }; res: boolean }; - 'settings:load': { req: void; res: Record }; - 'sessions:list': { - req: { limit?: number }; - res: Array<{ id: string; title?: string; cwd: string; updatedAt: string; model?: string }>; - }; - 'sessions:resume': { - req: { id: string }; - res: { history: StoredMessage[]; sessionId: string }; - }; - 'plugins:list': { - req: void; - res: Array<{ - name: string; - version: string; - enabled: boolean; - sourceHash: string; - trustedBy: 'user' | 'marketplace' | 'official'; - contributedHookEvents: string[]; - }>; - }; - 'plugins:install': { req: { spec: string }; res: { name: string; version: string } }; - 'plugins:setEnabled': { req: { name: string; enabled: boolean }; res: boolean }; - 'mcp:list': { - req: void; - res: Array<{ - name: string; - status: 'connected' | 'failed' | 'disabled'; - toolCount?: number; - error?: string; - }>; - }; - 'skills:list': { - req: void; - res: Array<{ - name: string; - description: string; - source: 'builtin' | 'user' | 'project' | 'plugin'; - path: string; - }>; - }; - 'skills:body': { req: { path: string }; res: string }; - /** - * Start an agent turn. Returns a turnId that subsequent events are tagged - * with via the 'agent:event' channel. - */ - 'agent:start': { - req: { - sessionId: string; - userMessage: string; - mode?: Mode; - model?: string; - allowedTools?: string[]; - }; - res: { turnId: string }; - }; - /** Abort an in-flight turn. */ - 'agent:abort': { req: { turnId: string }; res: boolean }; - /** - * Reply to an approval prompt that the agent surfaced via 'agent:event' - * with type 'approval_request'. - */ - 'agent:approve': { - req: { turnId: string; toolCallId: string; allow: boolean }; - res: void; - }; - /** Reply to an AskUserQuestion prompt. */ - 'agent:answer': { - req: { turnId: string; questionId: string; answer: string }; - res: void; - }; -} - -export type IpcChannel = keyof IpcRequestMap; - -// ────────────────────────────────────────────────────────────────────────── -// One-way events (main → renderer) -// ────────────────────────────────────────────────────────────────────────── - -export type AgentStreamEvent = - | ({ kind: 'event' } & AgentEvent & { turnId: string }) - | { - kind: 'approval_request'; - turnId: string; - toolCallId: string; - toolName: string; - toolInput: Record; - reason: string; - } - | { - kind: 'ask_user'; - turnId: string; - questionId: string; - question: string; - options: Array<{ label: string; description: string }>; - multiSelect?: boolean; - } - | { - kind: 'turn_done'; - turnId: string; - stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error'; - }; - -export interface IpcEventMap { - 'agent:event': AgentStreamEvent; - 'updater:update-downloaded': { version: string; releaseNotes?: string }; -} - -export type IpcEventChannel = keyof IpcEventMap; - -// ────────────────────────────────────────────────────────────────────────── -// Helpers for safer channel typing in the renderer/main code -// ────────────────────────────────────────────────────────────────────────── - -/** - * Type-level utility: pull out the request payload type for a channel. - */ -export type IpcRequest = IpcRequestMap[C]['req']; -/** - * Type-level utility: pull out the response type for a channel. - */ -export type IpcResponse = IpcRequestMap[C]['res']; - -/** - * Generate a fresh turn ID — used by the main process when starting a turn. - * Format: `turn--`. - */ -export function newTurnId(): string { - return `turn-${Date.now().toString(36)}-${randomBytes(12).toString('hex')}`; -} - -/** - * Generate a fresh question ID for an AskUserQuestion prompt. - */ -export function newQuestionId(): string { - return `q-${Date.now().toString(36)}-${randomBytes(12).toString('hex')}`; -} diff --git a/packages/core/src/providers/deepseek.ts b/packages/core/src/providers/deepseek.ts index aecda45..62526f8 100644 --- a/packages/core/src/providers/deepseek.ts +++ b/packages/core/src/providers/deepseek.ts @@ -48,14 +48,6 @@ export class DeepSeekProvider implements Provider { baseURL: this.baseURL, fetch: opts.fetch, // If authToken is set, the OpenAI SDK uses Bearer (correct for our dual-header design). - // - // The OpenAI SDK refuses to start in a "browser-like" environment by default to - // avoid users shipping API keys in pages served to untrusted clients. DeepCode is - // never that case: it's a CLI / VS Code extension / Tauri desktop app, all of which - // run on the user's own machine with the key in storage they control. In Node this - // flag is a no-op (the guard's `typeof window` check never trips); in the Tauri - // webview it disables the false-positive guard so the renderer-side provider works. - dangerouslyAllowBrowser: true, }); } diff --git a/packages/core/src/sessions/snapshots.test.ts b/packages/core/src/sessions/snapshots.test.ts index dab7f19..80e7ec9 100644 --- a/packages/core/src/sessions/snapshots.test.ts +++ b/packages/core/src/sessions/snapshots.test.ts @@ -58,6 +58,8 @@ describe('snapshots', () => { expect(snap?.existed).toBe(true); expect(snap?.reason).toBe('pre-Edit'); expect(snap?.filePath).toBe(path); + expect(snap?.capturedAtMs).toBeTypeOf('number'); + expect(new Date(snap!.capturedAtMs!).toISOString()).toBe(snap?.capturedAt); expect(await fs.readFile(snap!.blobPath, 'utf8')).toBe('original content'); }); diff --git a/packages/core/src/sessions/snapshots.ts b/packages/core/src/sessions/snapshots.ts index 9d0dc93..791930c 100644 --- a/packages/core/src/sessions/snapshots.ts +++ b/packages/core/src/sessions/snapshots.ts @@ -15,6 +15,8 @@ const execFileAsync = promisify(execFile); export interface Snapshot { filePath: string; capturedAt: string; + /** Numeric projection used by thin clients; absent on older manifests. */ + capturedAtMs?: number; reason: string; // e.g. "pre-Edit" / "post-Edit" / "pre-Bash" / "session-start" hash: string; size: number; @@ -79,14 +81,17 @@ export async function captureSnapshot(args: { const dir = snapshotsDirFor(args.sessionsRoot, args.sessionId); await fs.mkdir(dir, { recursive: true }); - const ts = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 15); + const capturedAtMs = Date.now(); + const capturedAt = new Date(capturedAtMs).toISOString(); + const ts = capturedAt.replace(/[-:.]/g, '').slice(0, 15); const blobName = `${String(args.seq).padStart(5, '0')}-${ts}-${hash}.blob`; const blobPath = join(dir, blobName); await fs.writeFile(blobPath, content); const snap: Snapshot = { filePath: absPath, - capturedAt: new Date().toISOString(), + capturedAt, + capturedAtMs, reason: args.reason, hash, size: content.byteLength, @@ -131,9 +136,11 @@ export async function captureGitCheckpoint(args: { } if (!ref) return null; + const capturedAtMs = Date.now(); const snap: Snapshot = { filePath: resolve(args.cwd), - capturedAt: new Date().toISOString(), + capturedAt: new Date(capturedAtMs).toISOString(), + capturedAtMs, reason: args.reason, hash: ref.slice(0, 16), size: 0, From 40ceec4dbc9c073baec81e4bbda179e383b55f75 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 1 Aug 2026 17:42:55 +0800 Subject: [PATCH 33/33] ci: enforce app-server release gates --- .github/workflows/ci.yml | 15 +- .github/workflows/release.yml | 74 +++++++- apps/vscode/.vscodeignore | 2 + docs/CODEX_ALIGNMENT_PLAN.md | 12 +- docs/HANDOFF.md | 2 +- docs/RELEASING.md | 49 ++++-- docs/design/release-gates-v1.md | 92 ++++++++++ package.json | 2 + scripts/check-docs.mjs | 14 ++ scripts/release-gate.mjs | 303 ++++++++++++++++++++++++++++++++ 10 files changed, 539 insertions(+), 26 deletions(-) create mode 100644 docs/design/release-gates-v1.md create mode 100644 scripts/release-gate.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74a1576..630f17c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,12 +68,17 @@ jobs: DC_SANDBOX_NET_TEST: '1' run: pnpm test - - name: Build - run: pnpm build + - name: Build + app-server release gate + run: pnpm release:check - - name: Package VS Code extension - if: runner.os == 'Linux' - run: pnpm --filter deepcode package --out "${RUNNER_TEMP}/deepcode.vsix" + - name: Upload release-gate diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: release-gate-${{ matrix.os }} + path: apps/vscode/dist/release-gate-report.json + if-no-files-found: ignore + retention-days: 7 link-check: name: Docs link check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21004f1..99f40c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: validate: name: Validate before release runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 outputs: version: ${{ steps.version.outputs.version }} channel: ${{ steps.version.outputs.channel }} @@ -30,8 +30,29 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - run: pnpm lint + - run: pnpm format:check - run: pnpm test - - run: pnpm build + - run: pnpm docs:check + - run: pnpm release:check + + - name: Install Chromium + run: pnpm --filter @deepcode/desktop exec playwright install --with-deps chromium + + - name: Exercise desktop protocol fixture + run: pnpm --filter @deepcode/desktop test:e2e + + - name: Upload validation diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: release-validation-diagnostics + path: | + apps/vscode/dist/release-gate-report.json + apps/desktop/playwright-report + apps/desktop/test-results + if-no-files-found: ignore + retention-days: 14 - id: version name: Parse version + channel from tag @@ -53,7 +74,9 @@ jobs: # ---------------------------------------------------------------------- publish-cli: name: Publish deepcode-cli to npm - needs: validate + # Avoid a partial release: do not publish npm until both installable + # desktop/editor artifacts have built successfully. + needs: [validate, build-vscode, build-mac] runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -83,6 +106,39 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # ---------------------------------------------------------------------- + # Build installable VS Code extension + # ---------------------------------------------------------------------- + build-vscode: + name: Build VS Code extension + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + + - name: Set extension version from tag + run: npm version "${{ needs.validate.outputs.version }}" --no-git-tag-version + working-directory: apps/vscode + + - name: Package VSIX + run: | + mkdir -p release-artifacts + pnpm --dir apps/vscode package \ + --out "../../release-artifacts/deepcode-${{ needs.validate.outputs.version }}.vsix" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: vscode-release + path: release-artifacts/deepcode-*.vsix + # ---------------------------------------------------------------------- # Build + sign Mac client (.dmg) via Tauri # ---------------------------------------------------------------------- @@ -193,7 +249,7 @@ jobs: # ---------------------------------------------------------------------- github-release: name: Publish GitHub Release - needs: [validate, publish-cli, build-mac] + needs: [validate, publish-cli, build-vscode, build-mac] runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -212,6 +268,12 @@ jobs: name: mac-release path: release-artifacts/ + - name: Download VS Code extension + uses: actions/download-artifact@v4 + with: + name: vscode-release + path: release-artifacts/ + - name: Generate release notes id: notes run: | @@ -231,4 +293,6 @@ jobs: body_path: release-notes.md prerelease: ${{ needs.validate.outputs.channel != 'stable' }} generate_release_notes: false - files: release-artifacts/*.dmg + files: | + release-artifacts/*.dmg + release-artifacts/*.vsix diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore index 200435f..af4c913 100644 --- a/apps/vscode/.vscodeignore +++ b/apps/vscode/.vscodeignore @@ -2,6 +2,8 @@ src/** scripts/** node_modules/** dist/*.map +dist/release-gate-report.json +dist/*.vsix dist/.tsbuildinfo **/*.test.* tsconfig.json diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index a04beef..a8f829a 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -350,7 +350,10 @@ model tool call - 已删除无生产引用的 core renderer IPC、desktop native mutation/tool facade 与 `chat` 路由别名; Tauri 仅保留 credential presence、文件/快照只读投影和 sidecar supervision。legacy session importer 继续只读,旧文件不迁移、不覆盖;用户文档已改为 app-server 架构。 -- release candidate、迁移演练、性能预算和回滚说明。 +- `pnpm release:check` 已对真实 VS Code/app-server/VSIX 产物执行包体预算、v1 capability、 + create/read、配置诊断、workspace diff、重启 read/resume 与薄客户端边界扫描;Ubuntu/macOS CI + 与 tag validate 共用该门禁,tag 还必须通过 desktop Playwright journey。迁移、隔离 home 回滚演练、 + fail-closed 协议升级和签名 DMG smoke test 已记录在 `docs/design/release-gates-v1.md`。 验收:端到端 golden journey、性能基线、安全 review、文档与 release gate。 @@ -379,9 +382,10 @@ model tool call ### 7.4 性能 -- PR 4 spike 记录 app-server/sidecar 冷启动、安装包体积与内存基线,再据实设预算。 -- 取消请求到子进程停止的目标预算在 PR 1 基准测试后锁定。 -- thread list 和长列表 UI 的 SLO 在有真实数据模型与 fixtures 后锁定,避免先写任意数字。 +- release gate 以实测产物锁定 extension 64 KiB、app-server 768 KiB、VSIX 256 KiB 上限。 +- 打包 app-server 冷启动 initialize 预算 5 秒,metadata request 2 秒,workspace diff 10 秒;报告随 + CI failure artifact 上传。提高预算必须附 before/after 数据和新增用户价值。 +- thread list、长列表 UI 与 provider 首 token 仍需真实分布数据后再锁 SLO,不用本地单样本伪造目标。 ## 8. 风险与缓解 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 2273e7c..a26adb1 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # DeepCode — Session Handoff -> **历史快照(2026-05,请勿作为当前操作手册)**:本文件用于追溯早期 renderer runtime 实现,下面的 commit、测试数、文件路径、发布步骤和待办均可能已过期。当前架构与推进顺序见 [`CODEX_ALIGNMENT_PLAN.md`](CODEX_ALIGNMENT_PLAN.md),app-server 边界见 [`design/app-server-packaging-v1.md`](design/app-server-packaging-v1.md),仓库操作规范见 [`../AGENTS.md`](../AGENTS.md)。 +> **历史快照(2026-05,请勿作为当前操作手册)**:本文件用于追溯早期 renderer runtime 实现,下面的 commit、测试数、文件路径、发布步骤和待办均可能已过期。当前架构与推进顺序见 [`CODEX_ALIGNMENT_PLAN.md`](CODEX_ALIGNMENT_PLAN.md),app-server 边界见 [`design/app-server-v1.md`](design/app-server-v1.md),仓库操作规范见 [`../AGENTS.md`](../AGENTS.md)。 A new Claude Code session can pick up DeepCode from this document alone. It's intentionally dense — read once top-to-bottom, then keep open as a map. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e0ad7bb..6b17410 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,15 +1,16 @@ # Releasing DeepCode Tag-driven CI pipeline. Push a `v0.X.Y` tag → GitHub Actions takes over: -validate → build CLI + publish to npm → build + sign + notarize Tauri DMG -→ create GitHub Release with both artifacts attached. +validate → package VSIX + build/sign/notarize Tauri DMG → publish CLI to npm → create GitHub Release +with the VSIX and DMG attached. npm publication waits for both installable artifacts so an artifact +build failure cannot create an avoidable partial release. ## One-time setup ### 1. GitHub Actions secrets Set these in repo settings → Secrets and variables → Actions → New -repository secret. All five are required for a successful Mac release. +repository secret. All six are required for the complete release graph. | Secret | Purpose | | ----------------------------- | ----------------------------------------------------------------- | @@ -63,19 +64,23 @@ git tag v0.1.3 git push origin v0.1.3 ``` -The `release.yml` workflow fires on any `v*` tag push and runs five jobs -serially: +The `release.yml` workflow fires on any `v*` tag push. Its validation and publication graph is: -1. **validate** — `pnpm typecheck` + `pnpm test` + `pnpm build` +1. **validate** — typecheck, lint, format, tests, docs, `pnpm release:check`, and the Playwright + desktop protocol journey. The release gate starts the real bundled app-server twice and verifies + protocol capabilities, thread persistence, thin-client boundaries, bundle budgets, and timing. 2. **publish-cli** — bumps `apps/cli/package.json` to the tag version, `pnpm publish` to npm registry. Beta / nightly tags get `--tag ` so `latest` stays on stable. -3. **build-mac** — macOS-14 runner, Rust + Tauri build, calls +3. **build-vscode** — synchronizes the extension version, rebuilds the app-server bundle, and + packages `deepcode-.vsix`. Marketplace publication remains a separate, credentialed + operation; the installable VSIX is attached to GitHub Releases. +4. **build-mac** — macOS-14 runner, Rust + Tauri build, calls `scripts/sign-and-notarize.sh` end-to-end. Outputs `DeepCode--arm64.dmg`. -4. **github-release** — generates release notes via +5. **github-release** — generates release notes via `scripts/gen-release-notes.ts` (groups PRs by label), creates - the GitHub Release, attaches the DMG. + the GitHub Release, and attaches the DMG and VSIX. ## Release channels @@ -143,7 +148,7 @@ download manually; the "Relaunch to update" flow lights up once the feed exists. - Verify: `npm view deepcode-cli@` shows the new version - Verify: `https://github.com/oratis/deepcode/releases/tag/v` - has the DMG attached + has the DMG and version-matched VSIX attached - Optional: announce in the README / homepage ## Local rehearsal @@ -155,7 +160,11 @@ Before pushing the tag for a real release, the same flow runs locally: pnpm install pnpm typecheck pnpm test -pnpm build +pnpm lint +pnpm format:check +pnpm docs:check +pnpm release:check +pnpm --filter @deepcode/desktop test:e2e bash scripts/sign-and-notarize.sh ``` @@ -163,6 +172,9 @@ The DMG lands at `apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/DeepCode__aarch64.dmg`. This is the same artifact CI would attach. +The exact automated contract, budgets, additive storage rules, and isolated-home rollback drill are +documented in [`design/release-gates-v1.md`](design/release-gates-v1.md). + ## Rollback GitHub Releases are independent — delete a release (or mark prerelease) @@ -172,3 +184,18 @@ via the GitHub UI to hide it from users. only within 72h of publish. If a CLI version needs urgent rollback past that window, publish a patched higher version instead and let users upgrade. + +For app-server data rollback, keep `~/.deepcode/sessions` intact. Rich `threads-v1` snapshots are an +additive projection and legacy sessions are never rewritten by import. Rehearse rollback only on a +copy or an isolated `DEEPCODE_HOME`; do not delete user session data to downgrade an application. + +## Post-build DMG smoke test + +Before promoting a release candidate: + +1. install the notarized DMG on a clean macOS account or isolated test machine; +2. confirm About reports the tag version and the app launches without a system Node installation; +3. create a thread, stream a response, approve one safe tool, and interrupt a second turn; +4. relaunch, resume the first thread, and open Files Source/Diff/History; +5. confirm configuration diagnostics contain no secret values and export a redacted bundle; +6. verify the previous app-server-capable build can read a copy of the candidate session home. diff --git a/docs/design/release-gates-v1.md b/docs/design/release-gates-v1.md new file mode 100644 index 0000000..efdb721 --- /dev/null +++ b/docs/design/release-gates-v1.md @@ -0,0 +1,92 @@ +# Release gates v1 + +Status: enforced +Owner: repository CI and tag-driven release workflow + +## Purpose + +DeepCode now has one trusted runtime boundary but several thin clients. A release must prove that +the bundled app-server still starts without development-time module resolution, advertises the +expected protocol, persists canonical threads across restart, and stays within measured packaging +budgets. Unit tests alone cannot establish those properties. + +`pnpm release:check` is the local and CI entrypoint. It builds the monorepo, produces the real VS +Code `extension.cjs` and `app-server.cjs` bundles, then runs `scripts/release-gate.mjs` against the +bundle with a temporary `DEEPCODE_HOME`. No provider credential is supplied or read. + +## Automated contract + +The gate fails unless all of the following hold: + +1. The extension bundle is at most 64 KiB, the app-server bundle is at most 768 KiB, and the + installable VSIX is at most 256 KiB and contains both bundles. +2. A cold app-server returns protocol version 1 within 5 seconds and advertises every required v1 + capability, including diagnostics, structured workspace diff, and review actions. +3. `thread/start`, `thread/read`, configuration diagnostics, and `workspace/diff` work against the + packaged server. Metadata requests have a 2-second budget; workspace diff has a 10-second budget. +4. After a graceful process shutdown, a new packaged server using the same temporary home can read + and resume the same canonical thread. +5. Desktop, VS Code, and LSP production sources do not import the provider, credentials, agent loop, + or `RuntimeHost`, and do not contain renderer credential escape hatches. + +The current budgets deliberately leave cross-platform CI headroom over the measured baseline. A +budget increase requires a PR description with before/after measurements and an explanation of the +new user value. Do not raise a limit only to turn a red gate green. + +The gate writes `apps/vscode/dist/release-gate-report.json`; CI uploads it on failure. The report +contains sizes, timings, capability results, and source-scan counts, never prompts, credentials, or +workspace file contents. + +## Other release evidence + +The root gate complements, rather than replaces, these checks: + +- typecheck, lint, formatting, unit/integration tests, build, and documentation checks on Ubuntu and + macOS; +- the Playwright desktop protocol journey, including approval, tool/usage events, session resume, + and Files Source/Diff/History; +- Cargo check and tests for Tauri supervision and read-only commands; +- the tag workflow build, Developer ID signing, notarization, and artifact verification. + +The browser fixture does not prove that a signed WebKit/Tauri bundle launches on every supported +macOS version. Release candidates still require the short post-build DMG smoke test in +[`docs/RELEASING.md`](../RELEASING.md). + +## Migration and rollback drill + +Canonical storage is additive: + +- rich lifecycle snapshots live in `~/.deepcode/threads-v1`; +- canonical message projections live in `~/.deepcode/sessions/*.v1.jsonl`; +- pre-migration session files remain read-only and are imported lazily; +- snapshot `capturedAtMs` is an additive field, while readers continue to accept ISO-only and older + desktop manifests. + +The automated restart journey is the minimum rollback drill for every commit. Before a release +candidate, also verify this sequence with an isolated home: + +1. create a thread with the candidate app-server; +2. stop it gracefully and start a fresh process; +3. read and resume the thread; +4. open the same session in the desktop fixture; +5. run the previous app-server-capable release against a copy of the isolated home and confirm that + canonical message history remains readable. + +Never test rollback against the real user home. Never rewrite or delete legacy session files as part +of rollback. To disable the new lifecycle projection while investigating, move a copy of +`threads-v1` out of an isolated test home; retain `sessions` as the recovery source. `logs` and +redacted `diagnostics` are non-authoritative and may be discarded without affecting threads. + +Protocol v1 clients fail closed on a version mismatch. If a server change cannot remain compatible, +ship a coordinated client/server version bump rather than silently interpreting a new shape as v1. + +## Failure policy + +- A capability, persistence, boundary-scan, or protocol failure blocks the release. +- A performance or bundle regression blocks the release until measured and accepted in the design + document. +- A flaky gate is treated as a gate defect: preserve the failing report, fix determinism, and rerun; + do not add blind retries to the protocol script. +- Signing/notarization failure blocks DMG publication. npm publication and GitHub release creation + already depend on the validated release graph; recovery uses a higher patch version rather than + mutating an artifact users may already have installed. diff --git a/package.json b/package.json index ebe6be2..2241bf5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "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", + "release:gate": "node scripts/release-gate.mjs", + "release:check": "pnpm build && pnpm --dir apps/vscode build && pnpm --dir apps/vscode package --out dist/deepcode-release-gate.vsix && pnpm release:gate", "spike:desktop-sidecar": "node scripts/spike-desktop-sidecar.mjs", "clean": "pnpm -r clean", "prepare": "husky || true" diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index b9595e3..7c80201 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -15,6 +15,8 @@ const required = [ 'docs/CODEX_ALIGNMENT_PLAN.md', 'docs/quickstart.md', 'docs/security-model.md', + 'docs/RELEASING.md', + 'docs/design/release-gates-v1.md', ]; for (const path of required) { @@ -26,6 +28,8 @@ const currentDocs = [ 'CONTRIBUTING.md', 'packages/core/README.md', 'docs/quickstart.md', + 'docs/RELEASING.md', + 'docs/design/release-gates-v1.md', ]; const staleCount = /(?:tests[- ]|测试[::]?\s*|测试\s+)[0-9]{2,}\s*(?:passing|passed|个测试通过)?/i; for (const path of currentDocs) { @@ -69,6 +73,16 @@ if (!read('CONTRIBUTING.md').includes('Node.js ≥ 22')) { failures.push('CONTRIBUTING.md: Node requirement must match package.json (>=22)'); } +const packageJson = JSON.parse(read('package.json')); +if (!packageJson.scripts?.['release:check']) { + failures.push('package.json: missing release:check entrypoint'); +} +for (const workflow of ['.github/workflows/ci.yml', '.github/workflows/release.yml']) { + if (!read(workflow).includes('pnpm release:check')) { + failures.push(`${workflow}: must enforce pnpm release:check`); + } +} + if (failures.length > 0) { process.stderr.write(`${failures.map((failure) => `- ${failure}`).join('\n')}\n`); process.exitCode = 1; diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs new file mode 100644 index 0000000..5138ea1 --- /dev/null +++ b/scripts/release-gate.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { Buffer } from 'node:buffer'; +import { glob, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import process from 'node:process'; +import { createInterface } from 'node:readline'; +import { clearTimeout, setTimeout } from 'node:timers'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const extensionBundle = join(root, 'apps/vscode/dist/extension.cjs'); +const appServerBundle = join(root, 'apps/vscode/dist/app-server.cjs'); +const vsixBundle = join(root, 'apps/vscode/dist/deepcode-release-gate.vsix'); +const reportPath = join(root, 'apps/vscode/dist/release-gate-report.json'); + +const budgets = { + extensionBytes: 64 * 1024, + appServerBytes: 768 * 1024, + vsixBytes: 256 * 1024, + initializeMs: 5_000, + metadataRequestMs: 2_000, + workspaceDiffMs: 10_000, +}; + +const report = { + status: 'failed', + platform: `${process.platform}-${process.arch}`, + budgets, + bundles: {}, + journeys: [], + architectureScan: {}, +}; + +let temporaryHome; + +try { + report.bundles = await verifyBundleBudgets(); + report.architectureScan = await verifyThinClients(); + temporaryHome = await mkdtemp(join(tmpdir(), 'deepcode-release-gate-')); + + const first = await runJourney(temporaryHome, async (server) => { + const initialized = await server.request('initialize', {}, budgets.initializeMs); + verifyCapabilities(initialized.result); + const thread = await server.request('thread/start', { cwd: root }, budgets.metadataRequestMs); + const threadId = requiredString(thread.result, 'id'); + const read = await server.request('thread/read', { threadId }, budgets.metadataRequestMs); + assert(read.result?.id === threadId, 'thread/read did not return the created thread'); + const diagnostics = await server.request( + 'config/diagnostics', + { cwd: root }, + budgets.metadataRequestMs, + ); + assert( + ['trusted', 'plan-only', 'untrusted'].includes(diagnostics.result?.trustStatus), + 'config/diagnostics returned an invalid trust state', + ); + const diff = await server.request('workspace/diff', { threadId }, budgets.workspaceDiffMs); + assert(typeof diff.result?.repository === 'boolean', 'workspace/diff shape is invalid'); + return { + threadId, + timings: { + initializeMs: initialized.durationMs, + threadStartMs: thread.durationMs, + threadReadMs: read.durationMs, + configDiagnosticsMs: diagnostics.durationMs, + workspaceDiffMs: diff.durationMs, + }, + }; + }); + report.journeys.push({ name: 'create-and-read', ...first }); + + const second = await runJourney(temporaryHome, async (server) => { + const initialized = await server.request('initialize', {}, budgets.initializeMs); + verifyCapabilities(initialized.result); + const read = await server.request( + 'thread/read', + { threadId: first.threadId }, + budgets.metadataRequestMs, + ); + assert(read.result?.id === first.threadId, 'thread was not durable across app-server restart'); + const resumed = await server.request( + 'thread/resume', + { threadId: first.threadId }, + budgets.metadataRequestMs, + ); + assert(resumed.result?.id === first.threadId, 'thread/resume failed after restart'); + return { + threadId: first.threadId, + timings: { + initializeMs: initialized.durationMs, + threadReadMs: read.durationMs, + threadResumeMs: resumed.durationMs, + }, + }; + }); + report.journeys.push({ name: 'restart-and-resume', ...second }); + report.status = 'ok'; +} catch (error) { + report.error = error instanceof Error ? error.message : String(error); + process.exitCode = 1; +} finally { + if (temporaryHome) await rm(temporaryHome, { recursive: true, force: true }); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8').catch(() => {}); + const output = `${JSON.stringify(report, null, 2)}\n`; + (process.exitCode ? process.stderr : process.stdout).write(output); +} + +async function verifyBundleBudgets() { + const [extension, appServer, vsix, vsixContent] = await Promise.all([ + stat(extensionBundle), + stat(appServerBundle), + stat(vsixBundle), + readFile(vsixBundle), + ]); + assert( + extension.size <= budgets.extensionBytes, + `extension bundle is ${extension.size} bytes; budget is ${budgets.extensionBytes}`, + ); + assert( + appServer.size <= budgets.appServerBytes, + `app-server bundle is ${appServer.size} bytes; budget is ${budgets.appServerBytes}`, + ); + assert( + vsix.size <= budgets.vsixBytes, + `VSIX is ${vsix.size} bytes; budget is ${budgets.vsixBytes}`, + ); + for (const entry of ['extension/dist/extension.cjs', 'extension/dist/app-server.cjs']) { + assert(vsixContent.includes(Buffer.from(entry)), `VSIX is missing ${entry}`); + } + return { + extensionBytes: extension.size, + appServerBytes: appServer.size, + vsixBytes: vsix.size, + }; +} + +async function verifyThinClients() { + const violations = []; + const patterns = [ + { + label: 'sensitive core runtime import', + pattern: + /(?:from\s+|require\()['"]@deepcode\/core(?:\/dist)?\/(?:agent|runtime|providers\/deepseek|credentials)(?:\.js)?['"]/, + }, + { + label: 'bare core runtime import', + pattern: /(?:from\s+|require\()['"]@deepcode\/core['"]/, + }, + { + label: 'runtime or credential implementation', + pattern: + /\b(?:new\s+DeepSeekProvider|new\s+RuntimeHost|dangerouslyAllowBrowser|DEEPSEEK_API_KEY)\b/, + }, + ]; + const roots = ['apps/desktop/src', 'apps/vscode/src', 'apps/lsp/src']; + let filesChecked = 0; + for (const sourceRoot of roots) { + for await (const path of glob(`${sourceRoot}/**/*.{ts,tsx}`, { cwd: root })) { + if (/\.(?:test|spec)\.[^.]+$/.test(path) || path.includes('/preview-')) continue; + filesChecked++; + const source = await readFile(join(root, path), 'utf8'); + for (const candidate of patterns) { + if (candidate.pattern.test(source)) violations.push(`${path}: ${candidate.label}`); + } + } + } + assert(violations.length === 0, `thin-client boundary violations:\n${violations.join('\n')}`); + return { filesChecked, violations }; +} + +function verifyCapabilities(result) { + assert(result?.protocolVersion === 1, `unsupported protocol version: ${result?.protocolVersion}`); + for (const capability of [ + 'threadResume', + 'turnInterrupt', + 'completedItemPersistence', + 'transientDeltas', + 'structuredToolEvents', + 'interactiveRequests', + 'configDiagnostics', + 'diagnosticExport', + 'workspaceDiff', + 'reviewActions', + ]) { + assert(result.capabilities?.[capability] === true, `missing capability: ${capability}`); + } +} + +async function runJourney(home, task) { + const server = startServer(home); + try { + return await task(server); + } finally { + await server.close(); + } +} + +function startServer(home) { + const startedAt = performance.now(); + const child = spawn(process.execPath, [appServerBundle], { + cwd: root, + env: sanitizedEnvironment(home), + stdio: ['pipe', 'pipe', 'pipe'], + }); + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + const pending = new Map(); + let sequence = 0; + let stderr = ''; + let disconnected; + const exited = new Promise((resolveExit) => { + child.once('exit', (code, signal) => { + disconnected = new Error(`app-server exited (code=${code}, signal=${signal})`); + for (const request of pending.values()) request.reject(disconnected); + pending.clear(); + resolveExit({ code, signal }); + }); + }); + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-8_192); + }); + lines.on('line', (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + disconnected = new Error('app-server emitted invalid JSON'); + return; + } + if (message.method === 'event') return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timeout); + if (message.error) request.reject(new Error(`${message.error.code}: ${message.error.message}`)); + else + request.resolve({ + result: message.result, + durationMs: round(performance.now() - request.at), + }); + }); + + return { + request(method, params, timeoutMs) { + if (disconnected) return Promise.reject(disconnected); + const id = ++sequence; + return new Promise((resolveRequest, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`${method} exceeded ${timeoutMs}ms`)); + }, timeoutMs); + pending.set(id, { + at: method === 'initialize' ? startedAt : performance.now(), + resolve: resolveRequest, + reject, + timeout, + }); + child.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + }); + }, + async close() { + if (child.exitCode === null && child.signalCode === null) child.stdin.end(); + const result = await Promise.race([ + exited, + new Promise((resolveExit) => + setTimeout(() => resolveExit({ code: null, signal: 'timeout' }), 5_000), + ), + ]); + if (result.signal === 'timeout') child.kill('SIGKILL'); + assert(result.code === 0, `app-server shutdown failed: ${stderr || JSON.stringify(result)}`); + }, + }; +} + +function sanitizedEnvironment(home) { + const env = { ...process.env, DEEPCODE_HOME: home, HOME: home }; + for (const key of [ + 'DEEPSEEK_API_KEY', + 'DEEPSEEK_AUTH_TOKEN', + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + ]) { + delete env[key]; + } + return env; +} + +function requiredString(value, key) { + const candidate = value?.[key]; + assert(typeof candidate === 'string' && candidate.length > 0, `missing string field: ${key}`); + return candidate; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function round(value) { + return Math.round(value * 100) / 100; +}