diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d8feb..151b181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,17 @@ 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" + 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 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/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/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/headless.ts b/apps/cli/src/headless.ts index c24213b..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). @@ -441,6 +444,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/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/src/repl.ts b/apps/cli/src/repl.ts index 150854d..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, @@ -762,6 +761,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/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/desktop/README.md b/apps/desktop/README.md index 0818901..acef184 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -15,13 +15,14 @@ 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 · + 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/ 权限能力声明 @@ -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。provider、agent loop、tools、权限、 +session materialization 和凭证明文都只存在于 sidecar;renderer 不再带有第二套运行时。 + ## 开发 依赖在 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/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/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/commands.rs b/apps/desktop/src-tauri/src/commands.rs index a839406..728f82b 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)] @@ -23,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] @@ -114,89 +116,126 @@ 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!("{}.jsonl", id)); - let header = serde_json::json!({ - "type": "session_meta", - "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> { - 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 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)?; 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![]), Err(e) => return Err(format!("read {}: {}", path.display(), e)), }; + 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()); 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,30 +245,18 @@ 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); } } 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 { @@ -296,13 +323,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(); @@ -320,7 +347,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") @@ -359,7 +388,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() { @@ -368,11 +397,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() @@ -407,11 +445,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 @@ -426,9 +470,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 @@ -773,6 +825,68 @@ 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 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/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 866d119..25bc2a4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,28 +3,32 @@ // // 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). -// -// The agent loop itself runs in the renderer via @deepcode/core — no Node -// runtime in main process means smaller binary + faster startup. +// 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; +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, - 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_edit, tool_glob, tool_grep, tool_read, tool_write}; +use tools::tool_read; use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -37,9 +41,14 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(VoiceState::default()) + .manage(AppServerState::default()) .invoke_handler(tauri::generate_handler![ get_app_info, - read_credentials, + app_server_start, + app_server_send, + app_server_stop, + app_server_status, + credential_status, save_credentials, load_settings_file, save_settings_file, @@ -47,8 +56,6 @@ pub fn run() { append_allow_matcher, load_keybindings, save_keybindings, - session_create, - session_append, session_read, session_set_title, session_delete, @@ -59,11 +66,6 @@ pub fn run() { cli_path, open_url, tool_read, - tool_write, - tool_edit, - tool_bash, - 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 3f43c07..016ff35 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 @@ -85,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(); @@ -127,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 // ────────────────────────────────────────────────────────────────────────── @@ -240,10 +259,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 +302,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 +335,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; - return Ok(BashOk { - stdout: String::new(), - stderr: format!("timeout after {}ms", timeout.as_millis()), - exit_code: 124, - timed_out, - }); + (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 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) // ────────────────────────────────────────────────────────────────────────── @@ -432,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 { @@ -454,17 +574,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-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/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, - // 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 033b1bb..0000000 --- a/apps/desktop/src/lib/mac-tools.ts +++ /dev/null @@ -1,340 +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'; - -/** - * 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): 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 }; - 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, - }; - } 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 new file mode 100644 index 0000000..ef9a32c --- /dev/null +++ b/apps/desktop/src/lib/protocol-client.test.ts @@ -0,0 +1,112 @@ +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, + structuredToolEvents: true, + interactiveRequests: 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.connect(); + expect(bridge.started).toBe(1); + 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..dadb197 --- /dev/null +++ b/apps/desktop/src/lib/protocol-client.ts @@ -0,0 +1,159 @@ +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; + 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); + } + + 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; + 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'})`, + ), + ); + 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..6e9b63d 100644 --- a/apps/desktop/src/lib/tauri-api.test.ts +++ b/apps/desktop/src/lib/tauri-api.test.ts @@ -10,16 +10,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; import { + appServerSend, + appServerStart, + 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() })); @@ -29,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'); }); }); @@ -60,19 +50,25 @@ 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', () => { + 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(); @@ -98,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 db7c053..dacaf28 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,18 +35,25 @@ export async function getAppInfo(): Promise { return invoke('get_app_info'); } -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 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 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 { @@ -157,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 }); @@ -177,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/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/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/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..b642321 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. @@ -131,16 +132,17 @@ async function handleRunAgent( void (async () => { try { const [ - { runAgent }, + { RuntimeHost }, { DeepSeekProvider }, - { ToolRegistry, BUILTIN_TOOLS }, + { 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, BUILTIN_TOOLS: m.BUILTIN_TOOLS, + SAFE_READONLY_TOOLS: m.SAFE_READONLY_TOOLS, })), import('@deepcode/core').then((m) => ({ resolveCredentials: m.resolveCredentials, @@ -161,13 +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, onEvent: (e) => { send({ jsonrpc: '2.0', @@ -207,8 +214,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/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..a8dd7d1 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,20 @@ +# @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 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`. + +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 +{ "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..36821da --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,40 @@ +{ + "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", + "build:sidecar": "node scripts/build-sidecar.mjs", + "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", + "esbuild": "^0.21.5", + "typescript": "^5.7.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=22" + } +} 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/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..771bb60 --- /dev/null +++ b/apps/server/src/default-runtime.ts @@ -0,0 +1,51 @@ +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( + home?: string, + options: { forceFileCredentials?: boolean } = {}, +): RuntimeHostExecutor { + 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 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.', + ); + } + return new RuntimeHost({ + provider: new DeepSeekProvider({ + apiKey: credentials.apiKey ?? '', + authToken: credentials.authToken, + baseURL: credentials.baseURL ?? settings.baseURL, + }), + tools: new ToolRegistry(BUILTIN_TOOLS), + cwd, + mode, + permissions: settings.permissions ?? { allow: [...SAFE_READONLY_TOOLS] }, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + }); + }, + sessionManager, + }); +} 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..eb59030 --- /dev/null +++ b/apps/server/src/run.ts @@ -0,0 +1,37 @@ +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 { CanonicalThreadStore } from './store.js'; +import { ProtocolLineWriter, serveStdio } from './stdio.js'; + +export interface RunAppServerOptions { + input: Readable; + 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(options.home, { + forceFileCredentials: options.forceFileCredentials, + }), + 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); + }, + }); + 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..3e7c8a8 --- /dev/null +++ b/apps/server/src/runtime-executor.test.ts @@ -0,0 +1,290 @@ +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, + 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'; + +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' }], +}; + +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 }, + }; + } +} + +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(); + 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), + ...protocolCallbacks(), + }); + + 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); + }); + + 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' }), + ]), + ); + }); + + 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 new file mode 100644 index 0000000..c97e1bd --- /dev/null +++ b/apps/server/src/runtime-executor.ts @@ -0,0 +1,177 @@ +import { + type AgentEvent, + type Effort, + type Mode, + type RuntimeHost, + type SessionManager, + 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, mode: Mode) => Promise | RuntimeHost; + systemPrompt?: string; + model?: string; + sessionManager?: SessionManager; +} + +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 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 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), + }; + } +} + +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) { + 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..b441358 --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,278 @@ +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, 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' } }], + }; + }, + }; + 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(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); + }); + + 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('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); + 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..f86c14a --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,414 @@ +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; + 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 { + 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; +} + +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({ + 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(); + this.cancelInteractions(turnId); + 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); + case 'approval/respond': + return this.respondToApproval(request.params); + case 'user-input/respond': + return this.respondToUserInput(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(); + this.cancelInteractions(turnId); + 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, + }); + }, + 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)); + 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.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, + ): 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/sidecar-entry.ts b/apps/server/src/sidecar-entry.ts new file mode 100644 index 0000000..446af74 --- /dev/null +++ b/apps/server/src/sidecar-entry.ts @@ -0,0 +1,15 @@ +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, + forceFileCredentials: true, +}).catch((error) => { + process.stderr.write(`DeepCode app-server fatal: ${(error as Error).message ?? String(error)}\n`); + process.exitCode = 1; +}); 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.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 new file mode 100644 index 0000000..ee7599e --- /dev/null +++ b/apps/server/src/store.ts @@ -0,0 +1,145 @@ +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); +} + +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`); + } +} + +/** + * 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/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/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 9fc5f16..c1e5099 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -89,13 +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), + 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', - cwd, 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)}`); @@ -155,13 +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), + 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', - cwd: this.vscodeMod.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), onEvent: (e) => { if (e.type === 'text_delta') { buffer += e.text; diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index 3cf0eb3..9790a3e 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。 @@ -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 @@ -284,6 +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;桌面 + 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 new file mode 100644 index 0000000..067be44 --- /dev/null +++ b/docs/adr/0001-desktop-runtime-sidecar.md @@ -0,0 +1,142 @@ +# 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. + +### Packaged implementation evidence + +The first implementation build on the same host adds the production-shaped artifacts: + +| Measurement | Result | +| ------------------------------------- | ---------------------- | +| Bundled CommonJS app-server | 229,175 bytes | +| Thin/stripped bundled runtime | 108,412,096 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 | + +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. + +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 + +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/docs/design/app-server-v1.md b/docs/design/app-server-v1.md new file mode 100644 index 0000000..4c0dffc --- /dev/null +++ b/docs/design/app-server-v1.md @@ -0,0 +1,94 @@ +# 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 | +| `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 +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 `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 +`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. + +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: + +```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 + +- 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 new file mode 100644 index 0000000..3d9ca15 --- /dev/null +++ b/docs/design/runtime-protocol-v1.md @@ -0,0 +1,66 @@ +# 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`, 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. + +## 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, +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 +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/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/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/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/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/package.json b/packages/core/package.json index 6d5a1e1..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" @@ -32,6 +36,26 @@ "types": "./dist/keybindings/vim.d.ts", "import": "./dist/keybindings/vim.js" }, + "./credentials": { + "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" + }, + "./sessions": { + "types": "./dist/sessions/index.d.ts", + "import": "./dist/sessions/index.js" + }, + "./tools": { + "types": "./dist/tools/index.d.ts", + "import": "./dist/tools/index.js" + }, "./skills/*": "./skills/*", "./package.json": "./package.json" }, diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index bca01a4..6e24040 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. @@ -119,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 () => { @@ -190,6 +203,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..6b85f51 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/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`. @@ -58,11 +59,12 @@ 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; - /** 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 +143,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 +186,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; @@ -209,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 @@ -291,8 +325,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, @@ -420,15 +454,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++; @@ -450,9 +485,12 @@ export async function runAgent(opts: RunAgentOptions): Promise { }, }); } catch (err) { + if (opts.signal?.aborted || (err as { name?: string }).name === 'AbortError') { + 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; @@ -473,9 +511,11 @@ 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: '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) { @@ -491,8 +531,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 @@ -522,42 +561,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 finish('aborted'); } + // '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 }); @@ -657,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. // @@ -718,8 +766,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/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/index.ts b/packages/core/src/index.ts index a685970..d6aa5d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,9 @@ export { SessionManager, defaultSessionsDir, newSessionId, + readSessionRecords, + SessionCorruptionError, + SessionWriterConflictError, captureSnapshot, captureGitCheckpoint, listSnapshots, @@ -65,6 +68,9 @@ export { type SessionMeta, type SessionFiles, type SessionManagerOpts, + type SessionDiagnostic, + type SessionFormat, + type SessionReadResult, type Snapshot, } from './sessions/index.js'; @@ -172,6 +178,18 @@ 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, + RuntimeHost, + createRuntimeHost, + resolveRuntimePolicy, + type RuntimeHostOptions, + type RuntimeTurnOptions, + 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/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/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; +} 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 new file mode 100644 index 0000000..c803ad7 --- /dev/null +++ b/packages/core/src/runtime/index.ts @@ -0,0 +1,12 @@ +export { + SAFE_DEFAULT_PERMISSIONS, + SAFE_READONLY_TOOLS, + resolveRuntimePolicy, + type RuntimePolicyInput, +} from './policy.js'; +export { + RuntimeHost, + createRuntimeHost, + type RuntimeHostOptions, + type RuntimeTurnOptions, +} from './host.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/sessions/index.ts b/packages/core/src/sessions/index.ts index bd70931..00d5211 100644 --- a/packages/core/src/sessions/index.ts +++ b/packages/core/src/sessions/index.ts @@ -7,8 +7,15 @@ export type { SessionManagerOpts } from './manager.js'; export { defaultSessionsDir, newSessionId, + readSessionRecords, + writeMeta, + SessionCorruptionError, + SessionWriterConflictError, type SessionMeta, type SessionFiles, + type SessionDiagnostic, + type SessionFormat, + type SessionReadResult, } from './storage.js'; export { captureSnapshot, diff --git a/packages/core/src/sessions/manager.ts b/packages/core/src/sessions/manager.ts index bd6b360..7f8146b 100644 --- a/packages/core/src/sessions/manager.ts +++ b/packages/core/src/sessions/manager.ts @@ -9,7 +9,7 @@ import { newSessionId, readMessages, readMeta, - touchSession, + replaceSession, writeMeta, type SessionMeta, } from './storage.js'; @@ -54,7 +54,10 @@ export class SessionManager { async append(sessionId: string, msg: StoredMessage): Promise { await appendMessage(this.root, sessionId, msg); - await touchSession(this.root, sessionId); + } + + async materialize(meta: SessionMeta, messages: StoredMessage[]): Promise { + await replaceSession(this.root, meta, messages); } async list(): Promise { diff --git a/packages/core/src/sessions/storage.test.ts b/packages/core/src/sessions/storage.test.ts index a903716..8580365 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,10 @@ import { newSessionId, readMessages, readMeta, + readSessionRecords, + replaceSession, + SessionCorruptionError, + SessionWriterConflictError, sessionFiles, writeMeta, } from './storage.js'; @@ -61,12 +65,114 @@ 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('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([]); }); + it('reads desktop header + typed message JSONL without changing its bytes', async () => { + const id = 'desktop-old'; + const path = sessionFiles(root, id).legacyJsonlPath; + 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).legacyJsonlPath, + `${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).legacyJsonlPath, + [ + 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,10 +190,72 @@ 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').legacyJsonlPath, + `${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'); - 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 6e1faf1..5774739 100644 --- a/packages/core/src/sessions/storage.ts +++ b/packages/core/src/sessions/storage.ts @@ -1,13 +1,49 @@ -// 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, 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 = 'canonical-v1' | '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 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; @@ -22,32 +58,53 @@ 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 null; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return records.meta; + } throw err; } } @@ -58,28 +115,273 @@ 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'); + }); +} + +/** + * 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 files = sessionFiles(root, sessionId); + 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; + 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: 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 { - return []; + // 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); + try { + 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; + for (let index = lines.length - 1; index >= 0; index--) { + if (lines[index]!.trim().length > 0) { + lastContentIndex = index; + break; + } } - const out: StoredMessage[] = []; - const rl = createInterface({ input: createReadStream(files.jsonlPath, { encoding: 'utf8' }) }); - for await (const line of rl) { + 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 = record.schema_version === 1 ? 'canonical-v1' : 'desktop-v0'; + meta ??= desktopMeta(record, updatedAt); + continue; + } + if (record.type === 'message') { + if (format !== 'canonical-v1') { + format = record.schema_version === 1 ? 'canonical-v1' : '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 +391,16 @@ 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('.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( - 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; } @@ -105,13 +411,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() 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/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; 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 { diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 0000000..4e304d1 --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,11 @@ +# @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 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/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..601b6a6 --- /dev/null +++ b/packages/protocol/src/codec.test.ts @@ -0,0 +1,53 @@ +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('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(['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) => { + 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..dea41ed --- /dev/null +++ b/packages/protocol/src/codec.ts @@ -0,0 +1,39 @@ +import type { + ProtocolMethod, + ProtocolNotification, + ProtocolRequest, + ProtocolResponse, +} from './types.js'; + +const protocolMethods = new Set([ + 'initialize', + 'thread/start', + 'thread/read', + 'thread/resume', + 'turn/start', + 'turn/interrupt', + 'approval/respond', + 'user-input/respond', +]); + +export function encodeProtocolMessage( + message: ProtocolRequest | ProtocolResponse | ProtocolNotification, +): 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..92f9092 --- /dev/null +++ b/packages/protocol/src/runtime.test.ts @@ -0,0 +1,154 @@ +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, + structuredToolEvents: true, + interactiveRequests: 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', + }); + 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[] = []; + 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..45af68b --- /dev/null +++ b/packages/protocol/src/runtime.ts @@ -0,0 +1,230 @@ +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, + structuredToolEvents: true, + interactiveRequests: 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 (isDurableEvent(event)) 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); + } +} + +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 new file mode 100644 index 0000000..59bc870 --- /dev/null +++ b/packages/protocol/src/types.ts @@ -0,0 +1,148 @@ +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 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; + turnId: string; + itemId: string; + delta: string; +} + +export type TransientProtocolEvent = + | TransientDeltaEvent + | ToolStartedEvent + | ToolCompletedEvent + | UsageUpdatedEvent + | ApprovalRequestedEvent + | UserInputRequestedEvent; + +export type ProtocolEvent = DurableProtocolEvent | TransientProtocolEvent; + +export interface InitializeResult { + protocolVersion: typeof PROTOCOL_VERSION; + capabilities: { + threadResume: true; + turnInterrupt: true; + completedItemPersistence: true; + transientDeltas: true; + structuredToolEvents: true; + interactiveRequests: true; + }; +} + +export type ProtocolMethod = + | 'initialize' + | 'thread/start' + | 'thread/read' + | 'thread/resume' + | 'turn/start' + | 'turn/interrupt' + | 'approval/respond' + | 'user-input/respond'; + +export interface ProtocolRequest { + id: string | number; + method: ProtocolMethod; + params: Record; +} + +export interface ProtocolResponse { + id: string | number | null; + result?: unknown; + error?: { code: string; message: string }; +} + +export interface ProtocolNotification { + method: 'event'; + params: ProtocolEvent; +} 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/pnpm-lock.yaml b/pnpm-lock.yaml index b25967a..5e1e9b2 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 @@ -57,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 @@ -129,6 +135,28 @@ 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 + esbuild: + specifier: ^0.21.5 + version: 0.21.5 + 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 +195,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/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 \ 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 }); +} diff --git a/tsconfig.json b/tsconfig.json index b2e6051..d3aba50 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,10 +3,12 @@ "include": [], "references": [ { "path": "./packages/core" }, + { "path": "./packages/protocol" }, { "path": "./packages/shared-ui" }, { "path": "./apps/cli" }, { "path": "./apps/desktop" }, { "path": "./apps/lsp" }, + { "path": "./apps/server" }, { "path": "./apps/vscode" } ] }