From c922a45a08794b744677f71dd4af45e1d3ffe584 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 18:57:37 +0800 Subject: [PATCH 1/5] feat(M7): inline approval UI with Always-allow persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission prompts now surface in the desktop UI as an inline panel above the composer with three buttons: Approve / Reject / Always allow. "Always allow" persists a bare-tool matcher to ~/.deepcode/settings.json so the rule survives across sessions. **Core changes:** - ApprovalCallback return type extended: boolean | 'always' - agent.ts treats 'always' same as true (allow-this-call); host is responsible for the persistence side - New appendAllowMatcher(path, matcher) helper in config/loader.ts — idempotent, creates file + permissions object if absent - Re-exported from @deepcode/core root index + config/index.ts **CLI:** - repl.ts prompt now accepts [y]es / [n]o / [a]lways - On 'a', calls appendAllowMatcher against project-local settings **Desktop (Tauri):** - New Rust command append_allow_matcher(matcher) targeting user-level ~/.deepcode/settings.json (renderer doesn't have a stable cwd concept) - mac-agent.ts onApproval expanded to return 'allow'|'deny'|'always' - window-shim.ts mints requestId per ask, emits permission_request event carrying { requestId, toolName, reason }, stashes resolver in a Map - agent.approve({ requestId, decision }) resolves the awaiting promise - ReplScreen.tsx renders inline panel above composer; locks input while pending; on 'always' calls appendAllowMatcher before dispatching **Tests:** 5 new vitest cases for appendAllowMatcher (create / preserve / idempotent / whitespace / pre-existing allow absent). 13 loader tests pass; all 533 workspace tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/repl.ts | 18 ++++- apps/desktop/src-tauri/src/commands.rs | 40 +++++++++++ apps/desktop/src-tauri/src/lib.rs | 5 +- apps/desktop/src/lib/mac-agent.ts | 15 +++- apps/desktop/src/lib/tauri-api.ts | 5 ++ apps/desktop/src/lib/window-shim.ts | 44 ++++++++++-- apps/desktop/src/screens/Repl.tsx | 96 ++++++++++++++++++++++++- apps/desktop/src/types/global.d.ts | 7 +- packages/core/src/agent.ts | 17 ++++- packages/core/src/config/index.ts | 1 + packages/core/src/config/loader.test.ts | 53 +++++++++++++- packages/core/src/config/loader.ts | 28 ++++++++ packages/core/src/index.ts | 1 + 13 files changed, 313 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 16b2ce7..c8c12b5 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -12,6 +12,7 @@ import { ToolRegistry, WebFetchTool, WriteTool, + appendAllowMatcher, applyStyle, buildSkillsDescriptionBlock, closeAllMcpServers, @@ -24,6 +25,7 @@ import { makeSkillTool, resolveCredentials, runAgent, + settingsPaths, wirePlugins, type DeepCodeSettings, type Effort, @@ -294,7 +296,21 @@ export async function startRepl(opts: ReplOpts): Promise { sandboxConfig: settings.sandbox, approval: async (toolName, _input, verdict) => { output.write(`\n ⏸ Approve ${toolName}? Reason: ${verdict.reason}\n`); - const answer = (await rl.question(' [y]es / [n]o: ')).trim().toLowerCase(); + const answer = ( + await rl.question(' [y]es / [n]o / [a]lways: ') + ).trim().toLowerCase(); + if (answer === 'a' || answer === 'always') { + // Persist a bare-tool matcher to project-local settings so the next + // run of this tool from this project skips the prompt. + try { + const { localPath } = settingsPaths({ cwd: ctx.cwd }); + await appendAllowMatcher(localPath, toolName); + output.write(` ✓ Added "${toolName}" to ${localPath} permissions.allow\n`); + } catch (err) { + output.write(` ⚠ Could not persist always-allow: ${(err as Error).message}\n`); + } + return 'always'; + } return answer === 'y' || answer === 'yes'; }, askUser: async (req) => { diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index db2d7f7..dd9caee 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -47,6 +47,46 @@ pub fn get_settings_path() -> Option { settings::user_settings_path() } +/// Append a matcher string to permissions.allow[] in ~/.deepcode/settings.json, +/// creating the file (and the permissions object) if needed. Idempotent. +/// +/// Called from the renderer when the user clicks "Always allow" on an +/// inline permission prompt. We deliberately target USER-level settings +/// (not project-local) because the renderer doesn't have a stable cwd +/// concept — the user can later tighten the rule by editing the file. +#[tauri::command] +pub fn append_allow_matcher(matcher: String) -> Result<(), String> { + let trimmed = matcher.trim(); + if trimmed.is_empty() { + return Ok(()); + } + let mut value = settings::read_user()?; + // Ensure `permissions.allow` is an array. + if !value.is_object() { + value = serde_json::json!({}); + } + let obj = value.as_object_mut().unwrap(); + let perms = obj + .entry("permissions".to_string()) + .or_insert_with(|| serde_json::json!({})); + if !perms.is_object() { + *perms = serde_json::json!({}); + } + let perms_obj = perms.as_object_mut().unwrap(); + let allow = perms_obj + .entry("allow".to_string()) + .or_insert_with(|| serde_json::json!([])); + if !allow.is_array() { + *allow = serde_json::json!([]); + } + let arr = allow.as_array_mut().unwrap(); + let exists = arr.iter().any(|v| v.as_str() == Some(trimmed)); + if !exists { + arr.push(serde_json::Value::String(trimmed.to_string())); + } + settings::write_user(&value) +} + /// List session files under ~/.deepcode/sessions/. Returns just metadata. #[derive(Serialize)] pub struct SessionMeta { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c8f0e62..8d52b0f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -15,8 +15,8 @@ mod settings; mod tools; use commands::{ - cli_path, get_app_info, get_settings_path, list_sessions, load_settings_file, open_url, - read_credentials, save_credentials, save_settings_file, + append_allow_matcher, cli_path, get_app_info, get_settings_path, list_sessions, + load_settings_file, open_url, read_credentials, save_credentials, save_settings_file, }; use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; use tauri::Manager; @@ -37,6 +37,7 @@ pub fn run() { load_settings_file, save_settings_file, get_settings_path, + append_allow_matcher, list_sessions, cli_path, open_url, diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts index 3c740b6..dfb0dc9 100644 --- a/apps/desktop/src/lib/mac-agent.ts +++ b/apps/desktop/src/lib/mac-agent.ts @@ -78,8 +78,15 @@ export interface StartTurnArgs { mode?: Mode; 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/deny. */ - onApproval?: (toolName: string, reason: string) => Promise; + /** 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'>; } export interface StartTurnResult { @@ -118,7 +125,9 @@ export async function startAgentTurn(args: StartTurnArgs): Promise { const reason = verdict.reason ?? `Approve ${toolName}?`; - return await args.onApproval!(toolName, reason); + const decision = await args.onApproval!(toolName, reason); + if (decision === 'always') return 'always'; + return decision === 'allow'; } : undefined, onEvent: args.onEvent, diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index 3537c26..8b8d2ac 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -64,6 +64,11 @@ export async function getSettingsPath(): Promise { return (await invoke('get_settings_path')) as string | null; } +/** Append a permissions matcher to ~/.deepcode/settings.json. Idempotent. */ +export async function appendAllowMatcher(matcher: string): Promise { + await invoke('append_allow_matcher', { matcher }); +} + export async function listSessions(): Promise { return (await invoke('list_sessions')) as SessionMeta[]; } diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index 95b153a..1f95e3e 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -6,6 +6,7 @@ import type { AgentEvent, Mode } from '@deepcode/core/dist/types.js'; import type { DeepCodeAPI } from '../types/global.js'; import { abortAgentTurn, startAgentTurn } from './mac-agent.js'; import { + appendAllowMatcher, getAppInfo, listSessions, loadSettingsFile, @@ -29,6 +30,19 @@ 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< + string, + (decision: 'allow' | 'deny' | 'always') => void +>(); + +function nextRequestId(): string { + return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; +} + export function installTauriShim(): void { const api: DeepCodeAPI = { async version() { @@ -100,6 +114,22 @@ export function installTauriShim(): void { 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, + }); + }); + }, }); pendingTurnId = result.turnId; return result; @@ -107,10 +137,16 @@ export function installTauriShim(): void { async abort({ turnId }) { return abortAgentTurn(turnId); }, - async approve() { - // Approval prompts are handled inline via the onApproval callback - // passed to startAgentTurn — not via this method. Kept for API - // shape compatibility. + 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); }, async answer() { // AskUserQuestion answers: same — for v1 Mac MVP we don't wire diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 0a4d4ea..e4c9a6a 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -3,6 +3,7 @@ // Milestone: M6 (real agent integration) import { useEffect, useRef, useState } from 'react'; +import { appendAllowMatcher } from '../lib/tauri-api.js'; interface Message { role: 'user' | 'assistant' | 'system' | 'tool'; @@ -21,6 +22,16 @@ interface AgentStreamEvt { result?: { content: string; isError?: boolean }; error?: string; stopReason?: string; + // permission_request fields + requestId?: string; + toolName?: string; + reason?: string; +} + +interface PendingApproval { + requestId: string; + toolName: string; + reason: string; } export function ReplScreen(): JSX.Element { @@ -35,6 +46,7 @@ export function ReplScreen(): JSX.Element { const [input, setInput] = useState(''); const [busy, setBusy] = useState(false); const [activeTurnId, setActiveTurnId] = useState(null); + const [pendingApproval, setPendingApproval] = useState(null); const listRef = useRef(null); // Subscribe to agent events for the lifetime of this view @@ -96,12 +108,50 @@ export function ReplScreen(): JSX.Element { { role: 'system', text: `✕ Error: ${e.error ?? 'unknown'}` }, ]); break; + case 'permission_request': + if (e.requestId && e.toolName) { + setPendingApproval({ + requestId: e.requestId, + toolName: e.toolName, + reason: e.reason ?? `Approve ${e.toolName}?`, + }); + } + break; // text 'usage', 'thinking_delta', 'turn_complete' silently dropped } }); return () => off(); }, []); + async function handleApproval( + decision: 'allow' | 'deny' | 'always', + ): Promise { + if (!pendingApproval) return; + const req = pendingApproval; + setPendingApproval(null); + if (decision === 'always') { + try { + await appendAllowMatcher(req.toolName); + setMessages((m) => [ + ...m, + { + role: 'system', + text: `✓ Added "${req.toolName}" to settings.permissions.allow`, + }, + ]); + } catch (err) { + setMessages((m) => [ + ...m, + { + role: 'system', + text: `⚠ Could not persist always-allow: ${(err as Error).message}`, + }, + ]); + } + } + await window.deepcode.agent.approve({ requestId: req.requestId, decision }); + } + useEffect(() => { listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' }); }, [messages]); @@ -158,14 +208,56 @@ export function ReplScreen(): JSX.Element { ))} + {pendingApproval && ( +
+
+ ⏸ Approval needed + {' — '} + {pendingApproval.toolName} +
+
+ {pendingApproval.reason} +
+
+ + + +
+
+ )}
setInput(e.target.value)} - placeholder={busy ? 'Agent is responding…' : 'Ask DeepCode…'} - disabled={busy} + placeholder={ + pendingApproval + ? 'Approve or reject the tool call above to continue…' + : busy + ? 'Agent is responding…' + : 'Ask DeepCode…' + } + disabled={busy || pendingApproval !== null} className="flex-1 rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent disabled:opacity-50" /> {busy ? ( diff --git a/apps/desktop/src/types/global.d.ts b/apps/desktop/src/types/global.d.ts index 1718c27..f1d91e2 100644 --- a/apps/desktop/src/types/global.d.ts +++ b/apps/desktop/src/types/global.d.ts @@ -71,7 +71,12 @@ export interface DeepCodeAPI { allowedTools?: string[]; }) => Promise<{ turnId: string }>; abort: (args: { turnId: string }) => Promise; - approve: (args: { turnId: string; toolCallId: string; allow: boolean }) => Promise; + /** Resolve an in-flight permission_request event. `decision === 'always'` + * also persists a matcher to ~/.deepcode/settings.json. */ + approve: (args: { + requestId: string; + decision: 'allow' | 'deny' | 'always'; + }) => Promise; answer: (args: { turnId: string; questionId: string; answer: string }) => Promise; onEvent: (cb: (e: unknown) => void) => () => void; }; diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 051b7f8..ae58f9e 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -23,14 +23,23 @@ import type { } from './types.js'; /** - * Approval callback — return true to allow, false to reject. + * Approval callback — return value semantics: + * - `true` allow this one call + * - `false` reject this one call + * - `'always'` allow this one call AND persist a matcher to + * `settings.local.json#permissions.allow` so future calls of + * the same tool skip the prompt. The host is responsible for + * the persist step (call `appendAllowMatcher` from + * `@deepcode/core/config`); the agent loop only treats + * `'always'` as allow-for-this-call. * Called when dispatcher returns 'ask'. */ +export type ApprovalDecision = boolean | 'always'; export type ApprovalCallback = ( toolName: string, toolInput: Record, verdict: DispatchVerdict, -) => Promise | boolean; +) => Promise | ApprovalDecision; export interface RunAgentOptions { provider: Provider; @@ -254,7 +263,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { }); let allowed = verdict.decision === 'allow'; if (verdict.decision === 'ask' && opts.approval) { - allowed = await opts.approval(toolUse.name, toolUse.input, verdict); + 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) { toolResults.push({ diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index feb4825..3c4d636 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -22,6 +22,7 @@ export { writeSettings, settingsPaths, deepMerge, + appendAllowMatcher, type LoadedSettings, type LoadSettingsOpts, } from './loader.js'; diff --git a/packages/core/src/config/loader.test.ts b/packages/core/src/config/loader.test.ts index 1feb3f5..8685591 100644 --- a/packages/core/src/config/loader.test.ts +++ b/packages/core/src/config/loader.test.ts @@ -3,7 +3,13 @@ import { promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { deepMerge, loadSettings, settingsPaths, writeSettings } from './loader.js'; +import { + appendAllowMatcher, + deepMerge, + loadSettings, + settingsPaths, + writeSettings, +} from './loader.js'; describe('settings loader', () => { let home: string; @@ -89,4 +95,49 @@ describe('settings loader', () => { expect(s.merged.permissions?.allow).toEqual(['Read']); expect(s.merged.permissions?.deny).toEqual(['Read(/etc/*)']); }); + + describe('appendAllowMatcher', () => { + it('creates the file and inserts the matcher when none exist', async () => { + const path = join(home, '.deepcode', 'settings.local.json'); + await appendAllowMatcher(path, 'Bash'); + const raw = JSON.parse(await fs.readFile(path, 'utf8')); + expect(raw.permissions.allow).toEqual(['Bash']); + }); + + it('preserves existing allow entries', async () => { + const path = join(home, '.deepcode', 'settings.local.json'); + await writeSettings(path, { + model: 'deepseek-chat', + permissions: { allow: ['Read'] }, + }); + await appendAllowMatcher(path, 'Bash'); + const raw = JSON.parse(await fs.readFile(path, 'utf8')); + expect(raw.permissions.allow).toEqual(['Read', 'Bash']); + expect(raw.model).toBe('deepseek-chat'); // unrelated fields untouched + }); + + it('is idempotent — does not duplicate', async () => { + const path = join(home, '.deepcode', 'settings.local.json'); + await appendAllowMatcher(path, 'Bash'); + await appendAllowMatcher(path, 'Bash'); + const raw = JSON.parse(await fs.readFile(path, 'utf8')); + expect(raw.permissions.allow).toEqual(['Bash']); + }); + + it('ignores empty / whitespace matchers', async () => { + const path = join(home, '.deepcode', 'settings.local.json'); + await appendAllowMatcher(path, ' '); + // file should not even be created + await expect(fs.access(path)).rejects.toThrow(); + }); + + it('handles allow being absent on existing file', async () => { + const path = join(home, '.deepcode', 'settings.local.json'); + await writeSettings(path, { permissions: { deny: ['Read'] } }); + await appendAllowMatcher(path, 'Bash'); + const raw = JSON.parse(await fs.readFile(path, 'utf8')); + expect(raw.permissions.allow).toEqual(['Bash']); + expect(raw.permissions.deny).toEqual(['Read']); + }); + }); }); diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 2dca42e..3b7868a 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -100,6 +100,34 @@ export async function writeSettings(path: string, settings: DeepCodeSettings): P await fs.writeFile(path, json, 'utf8'); } +/** + * Append a single matcher to `permissions.allow[]` inside the settings file + * at `path` (creating the file if it doesn't exist). Idempotent — does + * nothing if the matcher is already present. + * + * Used by the approval flow: when the user clicks "Always allow", the host + * calls this against the project-local settings.local.json so the rule + * survives across sessions. + */ +export async function appendAllowMatcher(path: string, matcher: string): Promise { + const trimmed = matcher.trim(); + if (!trimmed) return; + const existing = (await readJson(path)) ?? ({} as DeepCodeSettings); + const permissions = (existing.permissions ?? {}) as { + allow?: string[]; + deny?: string[]; + ask?: string[]; + }; + const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : []; + if (allow.includes(trimmed)) return; + allow.push(trimmed); + const next: DeepCodeSettings = { + ...existing, + permissions: { ...permissions, allow }, + }; + await writeSettings(path, next); +} + function resolveDir(p: string): string { return p.slice(0, p.lastIndexOf('/')); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4751c6a..5a29d8d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -72,6 +72,7 @@ export { writeSettings, settingsPaths, deepMerge, + appendAllowMatcher, evaluatePermission, matchRule, parseRule, From c64915859ba3d5d536afbb9fe337db5547d4d497 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 19:03:20 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(M7):=20/rewind=20slash=20command=20?= =?UTF-8?q?=E2=80=94=205=20ops=20over=20session=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /rewind to the CLI command set. Backed by the existing snapshot machinery from packages/core/src/sessions/snapshots.ts. Forms: /rewind list snapshots (latest first) /rewind code restore the file from that snapshot /rewind conversation trim history to messages before its capturedAt timestamp /rewind both code + conversation /rewind summarize-from keep head up to snap; compact the tail /rewind summarize-up-to compact the head; keep tail (= /compact pinned to a snapshot point) Implementation: - SessionContext gains optional history / provider / newHistory fields - REPL pre-populates ctx.history before each command call and applies ctx.newHistory after if the command set it - compact() reused from packages/core/src/compaction for both summarize ops - 5 new vitest cases (list empty, list populated, restore code, trim conversation by timestamp, reject bad seq) — 52/52 CLI tests pass - Also exports ApprovalDecision type (sibling to ApprovalCallback) Desktop UI for /rewind is deferred — the same listSnapshots / restoreSnapshot APIs are available from @deepcode/core/dist/sessions and can be surfaced via the existing FilePanel History tab in a follow-up. CLI is the primary surface for this milestone. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/commands.test.ts | 122 +++++++++++++++++++++++++++++ apps/cli/src/commands.ts | 140 ++++++++++++++++++++++++++++++++++ apps/cli/src/repl.ts | 9 +++ packages/core/src/index.ts | 2 +- 4 files changed, 272 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands.test.ts b/apps/cli/src/commands.test.ts index 537734a..0dd2113 100644 --- a/apps/cli/src/commands.test.ts +++ b/apps/cli/src/commands.test.ts @@ -212,4 +212,126 @@ describe('built-in command behavior', () => { const out = await reg.match('/todos')!.cmd.run([], ctx); expect(out.join('\n')).toMatch(/No active todos/); }); + + describe('/rewind', () => { + it('reports empty when no snapshots exist', async () => { + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create('/foo'); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match('/rewind')!.cmd.run([], ctx); + expect(out.join('\n')).toMatch(/No snapshots in this session yet/); + }); + + it('lists snapshots and explains action menu', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + // Create a real file + capture + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'v1'); + await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match('/rewind')!.cmd.run([], ctx); + const joined = out.join('\n'); + expect(joined).toMatch(/Snapshots \(1\)/); + expect(joined).toMatch(/pre-Edit/); + expect(joined).toMatch(/code/); + expect(joined).toMatch(/conversation/); + expect(joined).toMatch(/summarize-from/); + expect(joined).toMatch(/summarize-up-to/); + }); + + it('restores file content with `code` action', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'original'); + const snap = await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + // Modify the file after snapshot + await fs.writeFile(file, 'changed'); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match(`/rewind ${snap!.seq} code`)!.cmd.run( + [String(snap!.seq), 'code'], + ctx, + ); + expect(out.join('\n')).toMatch(/Restored/); + const after = await fs.readFile(file, 'utf8'); + expect(after).toBe('original'); + }); + + it('trims conversation with `conversation` action by capture timestamp', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'v1'); + // history: 1 message BEFORE snapshot, 1 AFTER + const before = { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'first' }], + timestamp: new Date(Date.now() - 60_000).toISOString(), + }; + // capture + const snap = await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + const after = { + role: 'user' as const, + content: [{ type: 'text' as const, text: 'second' }], + timestamp: new Date(Date.now() + 60_000).toISOString(), + }; + const ctx = makeContext({ + sessions: sm, + sessionId: meta.id, + history: [before, after], + }); + const out = await reg.match(`/rewind ${snap!.seq} conversation`)!.cmd.run( + [String(snap!.seq), 'conversation'], + ctx, + ); + expect(out.join('\n')).toMatch(/kept 1 of 2 messages/); + expect(ctx.newHistory).toEqual([before]); + }); + + it('rejects bad seq numbers', async () => { + const fs = await import('node:fs/promises'); + const reg = new CommandRegistry(); + const sm = new SessionManager({ root: sessRoot }); + const meta = await sm.create(sessRoot); + const file = join(sessRoot, 'a.txt'); + await fs.writeFile(file, 'v1'); + // Capture at least one snapshot so we move past the early-exit. + await sm.snapshot({ + sessionId: meta.id, + cwd: sessRoot, + filePath: file, + reason: 'pre-Edit', + seq: 1, + }); + const ctx = makeContext({ sessions: sm, sessionId: meta.id }); + const out = await reg.match('/rewind 999 code')!.cmd.run(['999', 'code'], ctx); + expect(out.join('\n')).toMatch(/No snapshot with seq #999/); + }); + }); }); diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index bdc2e20..d8ad0c6 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -4,8 +4,10 @@ import type { DeepCodeSettings, McpClientHandle, + Provider, SessionManager, SessionMeta, + StoredMessage, } from '@deepcode/core'; import { redact, type Credentials } from '@deepcode/core'; @@ -41,6 +43,12 @@ export interface SessionContext { * approve → write). Returns the path written, or null if user cancelled. */ initFlow?: () => Promise; + /** Current conversation history — REPL refreshes this before each command call. */ + history?: StoredMessage[]; + /** Provider for commands that need to call the LLM (e.g. /rewind summarize). */ + provider?: Provider; + /** Set by /rewind to request history replacement. REPL applies after run. */ + newHistory?: StoredMessage[]; } export interface SlashCommand { @@ -412,6 +420,137 @@ export const PluginsCommand: SlashCommand = { }, }; +export const RewindCommand: SlashCommand = { + name: '/rewind', + description: + 'List file snapshots and roll back (5 ops): /rewind [ code|conversation|both|summarize-from|summarize-up-to]', + async run(args, ctx) { + const { listSnapshots, restoreSnapshot, compact } = await import('@deepcode/core'); + const sessionsRoot = ctx.sessions.root; + const snaps = await listSnapshots({ sessionsRoot, sessionId: ctx.sessionId }); + + if (snaps.length === 0) { + return [ + 'No snapshots in this session yet.', + 'Snapshots are captured automatically before Edit / Write tool calls.', + ]; + } + + // No args → list snapshots in reverse chrono so the latest is at top. + if (args.length === 0) { + const lines = [`Snapshots (${snaps.length}):`, '']; + const top = [...snaps].reverse().slice(0, 20); + for (const s of top) { + const when = s.capturedAt.slice(11, 19); // HH:MM:SS + const file = trimMiddle(s.filePath, 50); + lines.push(` #${String(s.seq).padStart(3)} ${when} ${s.reason.padEnd(10)} ${file}`); + } + if (snaps.length > 20) lines.push(` ... and ${snaps.length - 20} older`); + lines.push(''); + lines.push('Rewind: /rewind '); + lines.push('Actions:'); + lines.push(' code — restore the file from this snapshot'); + lines.push(' conversation — trim history to before this snapshot'); + lines.push(' both — code + conversation'); + lines.push(' summarize-from — keep history up to here; summarize the rest'); + lines.push(' summarize-up-to — summarize history up to here; keep the rest'); + return lines; + } + + const seqArg = Number.parseInt(args[0] ?? '', 10); + if (!Number.isFinite(seqArg)) { + return [`Bad seq "${args[0]}". Run /rewind to list snapshots.`]; + } + const target = snaps.find((s) => s.seq === seqArg); + if (!target) { + return [`No snapshot with seq #${seqArg}. Valid: ${snaps.map((s) => s.seq).join(', ')}`]; + } + + const action = (args[1] ?? 'code').toLowerCase(); + const cutoffMs = Date.parse(target.capturedAt); + const currentHistory = ctx.history ?? []; + + switch (action) { + case 'code': { + await restoreSnapshot(target); + return [`✓ Restored ${target.filePath} from snapshot #${target.seq}`]; + } + case 'conversation': { + const kept = trimHistoryBefore(currentHistory, cutoffMs); + ctx.newHistory = kept; + return [ + `✓ Rewound conversation to snapshot #${target.seq} (kept ${kept.length} of ${currentHistory.length} messages).`, + ]; + } + case 'both': { + await restoreSnapshot(target); + const kept = trimHistoryBefore(currentHistory, cutoffMs); + ctx.newHistory = kept; + return [ + `✓ Restored ${target.filePath} from snapshot #${target.seq}`, + `✓ Rewound conversation (kept ${kept.length} of ${currentHistory.length} messages).`, + ]; + } + case 'summarize-from': { + if (!ctx.provider) return ['(/rewind summarize-from requires a provider — none configured.)']; + const kept = trimHistoryBefore(currentHistory, cutoffMs); + const tail = currentHistory.slice(kept.length); + if (tail.length === 0) { + return [`Nothing after snapshot #${target.seq} to summarize.`]; + } + const result = await compact(tail, { provider: ctx.provider }); + // New history: head (verbatim) + the compacted tail + ctx.newHistory = [...kept, ...result.history]; + return [ + `✓ Summarized ${tail.length} messages after snapshot #${target.seq} → ${result.history.length} kept.`, + ]; + } + case 'summarize-up-to': { + if (!ctx.provider) return ['(/rewind summarize-up-to requires a provider — none configured.)']; + const head = trimHistoryBefore(currentHistory, cutoffMs); + const tail = currentHistory.slice(head.length); + if (head.length === 0) { + return [`Nothing before snapshot #${target.seq} to summarize.`]; + } + const result = await compact(head, { provider: ctx.provider }); + // New history: compacted head + tail (verbatim) + ctx.newHistory = [...result.history, ...tail]; + return [ + `✓ Summarized ${head.length} messages up to snapshot #${target.seq} → ${result.history.length} kept.`, + ]; + } + default: + return [ + `Unknown action "${action}".`, + 'Valid: code | conversation | both | summarize-from | summarize-up-to', + ]; + } + }, +}; + +/** Keep messages with timestamp < cutoffMs. Falls back to a simple length-based + * heuristic if messages don't carry timestamps. */ +function trimHistoryBefore(history: StoredMessage[], cutoffMs: number): StoredMessage[] { + const out: StoredMessage[] = []; + for (const msg of history) { + const ts = msg.timestamp ? Date.parse(msg.timestamp) : NaN; + if (Number.isFinite(ts) && ts < cutoffMs) { + out.push(msg); + } else if (!Number.isFinite(ts)) { + // No timestamp: include — better to over-keep than to drop a turn the + // user didn't intend to lose. The conversation can be re-trimmed. + out.push(msg); + } + } + return out; +} + +function trimMiddle(s: string, maxLen: number): string { + if (s.length <= maxLen) return s; + const keep = Math.floor((maxLen - 1) / 2); + return s.slice(0, keep) + '…' + s.slice(s.length - keep); +} + export const BUILTIN_COMMANDS: SlashCommand[] = [ HelpCommand, ClearCommand, @@ -431,6 +570,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ PluginsCommand, KeybindingsCommand, VimCommand, + RewindCommand, ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index c8c12b5..59bf801 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -228,6 +228,9 @@ export async function startRepl(opts: ReplOpts): Promise { ...(pluginsWire?.spawnFailures.map((n) => `${n}: failed to start`) ?? []), ], initFlow: () => runInitFlow({ cwd, output, rl, provider, model, maxTokens, temperature }), + // M7: /rewind needs access to history + provider. + provider, + history, }; output.write(`\n ▎ DeepCode · ${ctx.model} · mode: ${ctx.mode} · effort: ${ctx.effort}\n`); @@ -264,6 +267,8 @@ export async function startRepl(opts: ReplOpts): Promise { // Slash command? const match = commands.match(userInput); if (match) { + // Refresh ctx.history snapshot before running — /rewind reads it. + ctx.history = history; const lines = await Promise.resolve(match.cmd.run(match.args, ctx)); for (const line of lines) output.write(line + '\n'); output.write('\n'); @@ -271,6 +276,10 @@ export async function startRepl(opts: ReplOpts): Promise { history = []; ctx.clearHistory = false; } + if (ctx.newHistory) { + history = ctx.newHistory; + ctx.newHistory = undefined; + } if (ctx.exitRequested) break; continue; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a29d8d..c4de606 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -155,7 +155,7 @@ export { } from './compaction/index.js'; // Agent loop's approval callback type (M3b) -export type { ApprovalCallback } from './agent.js'; +export type { ApprovalCallback, ApprovalDecision } from './agent.js'; // Skills (M4 — SKILL.md frontmatter loading + system-prompt builder; M5 — Skill tool) export { From f1f7ddc64f9482986627861b05d1b3e46ef36157 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 19:06:44 +0800 Subject: [PATCH 3/5] feat(M8): DEEPCODE_EFFORT_LEVEL env var + desktop effort selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: /effort and --effort already existed; this adds the third tier of the precedence chain from docs/DEVELOPMENT_PLAN.md §3.13c: --effort flag → DEEPCODE_EFFORT_LEVEL env → settings.effortLevel → 'medium' Pulled the precedence logic into a pure helper `resolveEffort()` in parse-args.ts so it's tested independently of REPL bootstrap. 7 new vitest cases cover cli-wins, env-wins, settings-wins, default, invalid env, trim, empty. Desktop: adds a small effort dropdown above the composer in REPL screen. Choice persists to ~/.deepcode/settings.json#effortLevel via the existing save_settings_file Tauri command, so it survives restart. Loaded on mount. Backend wiring: mac-agent.ts now looks up EFFORT_PARAMS[effort] and passes maxTokens + temperature to runAgent (previously relied on provider defaults). startAgentTurn() and window-shim agent.start() both accept the new effort parameter. All 59 CLI tests + workspace typecheck pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/parse-args.test.ts | 38 +++++++++++++++++- apps/cli/src/parse-args.ts | 25 ++++++++++++ apps/cli/src/repl.ts | 10 ++++- apps/desktop/src/lib/mac-agent.ts | 11 ++++- apps/desktop/src/lib/window-shim.ts | 9 ++++- apps/desktop/src/screens/Repl.tsx | 62 ++++++++++++++++++++++++++++- apps/desktop/src/types/global.d.ts | 3 ++ 7 files changed, 153 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/parse-args.test.ts b/apps/cli/src/parse-args.test.ts index eeb2ce1..ee63c9c 100644 --- a/apps/cli/src/parse-args.test.ts +++ b/apps/cli/src/parse-args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseArgs } from './parse-args.js'; +import { parseArgs, resolveEffort } from './parse-args.js'; describe('parseArgs', () => { it('parses empty argv', () => { @@ -129,3 +129,39 @@ describe('parseArgs', () => { expect(p.unknownFlags).toEqual([]); }); }); + +describe('resolveEffort (precedence)', () => { + it('cli flag wins over env and settings', () => { + expect( + resolveEffort({ cliFlag: 'high', envVar: 'low', settingsLevel: 'max' }), + ).toBe('high'); + }); + + it('env var wins when no cli flag', () => { + expect(resolveEffort({ envVar: 'xhigh', settingsLevel: 'low' })).toBe( + 'xhigh', + ); + }); + + it('settings wins when no cli flag and no env', () => { + expect(resolveEffort({ settingsLevel: 'low' })).toBe('low'); + }); + + it('defaults to medium when nothing is set', () => { + expect(resolveEffort({})).toBe('medium'); + }); + + it('ignores invalid env var', () => { + expect(resolveEffort({ envVar: 'ultra', settingsLevel: 'low' })).toBe( + 'low', + ); + }); + + it('trims whitespace in env var', () => { + expect(resolveEffort({ envVar: ' high ' })).toBe('high'); + }); + + it('ignores empty env var', () => { + expect(resolveEffort({ envVar: '', settingsLevel: 'max' })).toBe('max'); + }); +}); diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 9035bf2..a926ce2 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -68,6 +68,31 @@ const VALID_MODES: Mode[] = [ ]; const VALID_EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; +/** + * Resolve the effective effort level from all precedence layers. + * Order (high → low): cli flag → DEEPCODE_EFFORT_LEVEL env → settings → default. + * Spec: docs/DEVELOPMENT_PLAN.md §3.13c. + * + * Returns `'medium'` if nothing produces a valid value. + */ +export function resolveEffort(args: { + cliFlag?: string; + envVar?: string; + settingsLevel?: string; +}): Effort { + const candidates: Array = [ + args.cliFlag, + args.envVar?.trim(), + args.settingsLevel, + ]; + for (const c of candidates) { + if (c && (VALID_EFFORTS as string[]).includes(c)) { + return c as Effort; + } + } + return 'medium'; +} + export function parseArgs(argv: string[]): ParsedArgs { const out: ParsedArgs = { showHelp: false, diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 59bf801..68887db 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -38,6 +38,7 @@ import { import { createInterface } from 'node:readline/promises'; import type { Readable, Writable } from 'node:stream'; import { CommandRegistry, type SessionContext } from './commands.js'; +import { resolveEffort } from './parse-args.js'; export interface ReplOpts { input: Readable; @@ -89,7 +90,14 @@ export async function startRepl(opts: ReplOpts): Promise { const model = opts.model ?? settings.model ?? 'deepseek-chat'; const mode = (opts.mode ?? settings.permissions?.defaultMode ?? 'default') as Mode; - const effort = opts.effort ?? settings.effortLevel ?? 'medium'; + // Precedence: --effort flag → DEEPCODE_EFFORT_LEVEL env → settings.effortLevel → default. + // Spec: docs/DEVELOPMENT_PLAN.md §3.13c. (/effort runtime switch and skill + // frontmatter override happen later in the loop, not at construction time.) + const effort = resolveEffort({ + cliFlag: opts.effort, + envVar: process.env.DEEPCODE_EFFORT_LEVEL, + settingsLevel: settings.effortLevel, + }); const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; const sessions = new SessionManager(); diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts index dfb0dc9..e46d0c6 100644 --- a/apps/desktop/src/lib/mac-agent.ts +++ b/apps/desktop/src/lib/mac-agent.ts @@ -12,9 +12,13 @@ // avoid pulling BUILTIN_TOOLS / SessionManager / etc. at module-load time. // The renderer can't link against node:fs / node:child_process. import { runAgent } from '@deepcode/core/dist/agent.js'; -import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; +import { + DeepSeekProvider, + EFFORT_PARAMS, +} from '@deepcode/core/dist/providers/deepseek.js'; import type { AgentEvent, + Effort, Mode, ToolHandler, } from '@deepcode/core/dist/types.js'; @@ -76,6 +80,8 @@ export interface StartTurnArgs { userMessage: string; model?: string; mode?: Mode; + /** Effort tier — controls maxTokens + temperature. Default 'medium'. */ + effort?: Effort; 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: @@ -108,6 +114,7 @@ export async function startAgentTurn(args: StartTurnArgs): Promise { try { + const effortParams = EFFORT_PARAMS[args.effort ?? 'medium']; const result = await runAgent({ provider: prov, tools, @@ -115,6 +122,8 @@ export async function startAgentTurn(args: StartTurnArgs): Promise emitEvent({ kind: 'event', turnId: pendingTurnId, ...e }), onDone: (reason) => diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index e4c9a6a..6f51334 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -3,7 +3,21 @@ // Milestone: M6 (real agent integration) import { useEffect, useRef, useState } from 'react'; -import { appendAllowMatcher } from '../lib/tauri-api.js'; +import { + appendAllowMatcher, + loadSettingsFile, + saveSettingsFile, +} from '../lib/tauri-api.js'; + +type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +const EFFORT_LABELS: Record = { + low: 'Standard', + medium: 'Standard+', + high: 'High', + xhigh: 'Extra High', + max: 'Max', +}; +const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; interface Message { role: 'user' | 'assistant' | 'system' | 'tool'; @@ -47,8 +61,34 @@ export function ReplScreen(): JSX.Element { const [busy, setBusy] = useState(false); const [activeTurnId, setActiveTurnId] = useState(null); const [pendingApproval, setPendingApproval] = useState(null); + const [effort, setEffort] = useState('medium'); const listRef = useRef(null); + // Load saved effort on mount. + useEffect(() => { + void (async () => { + try { + const s = (await loadSettingsFile()) as { effortLevel?: string }; + if (s.effortLevel && EFFORTS.includes(s.effortLevel as Effort)) { + setEffort(s.effortLevel as Effort); + } + } catch { + /* fall back to default */ + } + })(); + }, []); + + async function handleEffortChange(next: Effort): Promise { + setEffort(next); + // Persist to ~/.deepcode/settings.json so the choice survives restart. + try { + const current = (await loadSettingsFile()) as Record; + await saveSettingsFile({ ...current, effortLevel: next }); + } catch (err) { + console.warn('Failed to persist effort:', err); + } + } + // Subscribe to agent events for the lifetime of this view useEffect(() => { if (!window.deepcode?.agent) return; @@ -167,6 +207,7 @@ export function ReplScreen(): JSX.Element { const r = await window.deepcode.agent.start({ sessionId: 'default', userMessage: text, + effort, }); setActiveTurnId(r.turnId); } catch (err) { @@ -245,6 +286,25 @@ export function ReplScreen(): JSX.Element {
)} +
+ + + + controls max tokens + temperature for each turn + +
Promise<{ turnId: string }>; abort: (args: { turnId: string }) => Promise; From 8ebb0110cb1e62854a6c10936dd5c9e26f9dda31 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 21:16:09 +0800 Subject: [PATCH 4/5] feat(M8): cron daemon install/uninstall scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/install-cron-daemon.sh and scripts/uninstall-cron-daemon.sh to wire up the DeepCode scheduled-tasks LaunchAgent (referenced from packages/core/src/launchd/index.ts). install-cron-daemon.sh: - Locates the deepcode binary (DEEPCODE_BIN env, then $PATH, then apps/cli/dist/index.js) - Writes ~/Library/LaunchAgents/dev.deepcode.scheduler.plist inline (avoids a runtime Node dep — plist format mirrors buildPlist() in the core launchd module) - Idempotent: unloads any existing copy first - Configurable interval (DEEPCODE_INTERVAL, default 60s) and subcommand (DEEPCODE_SUBCMD, default "scheduler run") - launchctl load -w to make launchd start firing uninstall-cron-daemon.sh: - launchctl unload -w + rm plist - Idempotent — runs cleanly when nothing is installed - Keeps log files under ~/.deepcode/scheduler.{log,err.log} Both are macOS-only (early-exit with a helpful pointer at the Linux systemd alternative described in DEVELOPMENT_PLAN.md §3.15.4). Smoke-tested with stubbed launchctl + tempdir HOME: plist content matches the format produced by core's buildPlist() exactly. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/install-cron-daemon.sh | 100 +++++++++++++++++++++++++++++++ scripts/uninstall-cron-daemon.sh | 32 ++++++++++ 2 files changed, 132 insertions(+) create mode 100755 scripts/install-cron-daemon.sh create mode 100755 scripts/uninstall-cron-daemon.sh diff --git a/scripts/install-cron-daemon.sh b/scripts/install-cron-daemon.sh new file mode 100755 index 0000000..a92252e --- /dev/null +++ b/scripts/install-cron-daemon.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Install the DeepCode scheduled-tasks LaunchAgent. +# +# Writes ~/Library/LaunchAgents/dev.deepcode.scheduler.plist and then +# `launchctl load -w`s it so launchd starts firing every $DEEPCODE_INTERVAL +# seconds (default 60). +# +# Usage: +# scripts/install-cron-daemon.sh # auto-detects binary +# DEEPCODE_BIN=/usr/local/bin/deepcode scripts/install-cron-daemon.sh +# DEEPCODE_INTERVAL=120 scripts/install-cron-daemon.sh # fire every 2 min +# +# Re-running is safe (idempotent — unloads any existing copy first). +# +# The plist XML below MUST match the format produced by +# packages/core/src/launchd/index.ts#buildPlist. There's a unit test that +# pins the format — update both in lockstep. + +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "ERROR: install-cron-daemon.sh is macOS-only (uses launchctl)." + echo "For Linux, write a systemd timer — see docs/DEVELOPMENT_PLAN.md §3.15.4." + exit 1 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Locate the deepcode binary. Allow override; otherwise prefer $(which deepcode), +# falling back to the locally-built CLI dist. +BIN="${DEEPCODE_BIN:-}" +if [[ -z "$BIN" ]]; then + if command -v deepcode >/dev/null 2>&1; then + BIN="$(command -v deepcode)" + elif [[ -x "$ROOT/apps/cli/dist/index.js" ]]; then + BIN="$ROOT/apps/cli/dist/index.js" + else + echo "ERROR: could not find a deepcode binary." + echo "Run 'pnpm --filter deepcode-cli build' or set DEEPCODE_BIN=/path/to/deepcode." + exit 1 + fi +fi + +INTERVAL="${DEEPCODE_INTERVAL:-60}" +PLIST_LABEL="dev.deepcode.scheduler" +PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist" +LOG_DIR="$HOME/.deepcode" + +mkdir -p "$LOG_DIR" "$(dirname "$PLIST_PATH")" + +# Idempotency: unload any prior copy before re-writing. +if [[ -f "$PLIST_PATH" ]]; then + echo "==> Existing plist found — unloading first ..." + launchctl unload -w "$PLIST_PATH" 2>/dev/null || true +fi + +echo "==> Writing $PLIST_PATH ..." + +# Split the subcommand into separate elements (default: "scheduler run") +SUBCMD="${DEEPCODE_SUBCMD:-scheduler run}" +ARGS_XML="" +ARGS_XML+=" ${BIN}"$'\n' +for word in $SUBCMD; do + ARGS_XML+=" ${word}"$'\n' +done + +cat > "$PLIST_PATH" < + + + + Label + ${PLIST_LABEL} + ProgramArguments + +${ARGS_XML} + StartInterval + ${INTERVAL} + StandardOutPath + ${LOG_DIR}/scheduler.log + StandardErrorPath + ${LOG_DIR}/scheduler.err.log + RunAtLoad + + + +EOF + +echo "==> launchctl load -w $PLIST_PATH ..." +launchctl load -w "$PLIST_PATH" + +echo "" +echo "==> DONE. DeepCode scheduler is now active." +echo " binary: $BIN" +echo " interval: ${INTERVAL}s" +echo " logs: $LOG_DIR/scheduler.log" +echo " errors: $LOG_DIR/scheduler.err.log" +echo " status: launchctl list | grep $PLIST_LABEL" +echo " stop: scripts/uninstall-cron-daemon.sh" diff --git a/scripts/uninstall-cron-daemon.sh b/scripts/uninstall-cron-daemon.sh new file mode 100755 index 0000000..03de3d8 --- /dev/null +++ b/scripts/uninstall-cron-daemon.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# Uninstall the DeepCode scheduled-tasks LaunchAgent. +# Unloads from launchd, then removes the plist file. Idempotent. + +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "ERROR: uninstall-cron-daemon.sh is macOS-only." + exit 1 +fi + +PLIST_LABEL="dev.deepcode.scheduler" +PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist" + +if [[ ! -f "$PLIST_PATH" ]]; then + echo "==> Nothing to do — no plist at $PLIST_PATH" + # Defensive: also try to unload by label in case the file was removed + # by hand but launchd still has a handle. + launchctl remove "$PLIST_LABEL" 2>/dev/null || true + exit 0 +fi + +echo "==> launchctl unload -w $PLIST_PATH ..." +launchctl unload -w "$PLIST_PATH" 2>/dev/null || true + +echo "==> Removing $PLIST_PATH ..." +rm -f "$PLIST_PATH" + +echo "" +echo "==> DONE. DeepCode scheduler is uninstalled." +echo " (Logs under ~/.deepcode/scheduler.{log,err.log} were kept.)" From 8e768eff5459a6288f301eddd09321b4841f2ac3 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 21:20:10 +0800 Subject: [PATCH 5/5] feat(M8): Vim mode wired into desktop composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VimState class + DEFAULT_KEYBINDINGS already lived in packages/core/src/keybindings/index.ts but couldn't be imported from the Tauri renderer because the same file pulled in node:fs. Split the pure half (types, VimState, resolveKeyAction, normalizeChord, DEFAULT_KEYBINDINGS) into ./vim.ts, kept loadKeybindings/saveKeybindings in index.ts, and added a subpath export so the renderer can import just the pure module. Desktop wiring: - New Tauri commands load_keybindings / save_keybindings backed by ~/.deepcode/keybindings.json (mirrors core's loader) - Repl.tsx loads the user's config on mount; if `vim: true`, instantiates a VimState - Composer changed from to