diff --git a/.gitignore b/.gitignore index b3452621..65b0bb82 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ tmp dist ts-dist .turbo -# Web/edge build artifacts (nitro .output, cloudflare .wrangler) — e.g. packages/console, packages/stats +# Web/edge build artifacts (nitro .output, cloudflare .wrangler) - e.g. packages/console, packages/stats .output .wrangler packages/console/app/public/sitemap.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8e91a1..adb2d32a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## V4.0.4 - Contract-gap closure (engine-decoupled) + +- Fix Plan Gate deadlock: stale-plan latch now warns (never hard-blocks) on tool execution, aligned with codex exec-policy philosophy. A mutating tool on a stale plan receives a reminder but always runs. +- Fix goal-loop scorer false positives: `extractValidationResults` now scopes extraction to declared validation commands only (toolCallId→command mapping), with latest-wins per command. Diagnostic bash calls no longer poison the score. +- Fix cancel/loop test flake: `maxRetries:0` in the test provider config prevents AI SDK exponential-backoff retries against the intentionally-dead test URL; per-test budgets raised from 3 s to 15 s. +- Release Desktop 1.4.2 with DeepAgent Core V4.0.4. +- Publishing truth: fix quick-start command (`deepagent-code run`), comment out unpublished npm install, unify domain to `deepagent.ltd`, replace `lessweb`/`anomalyco` org handles, update SECURITY.md supported-version line and M-CRED status, update CHANGELOG. + +## V4.0.3 - Upstream kernel alignment (AppNode foundation) + +- AppNode foundation: additive node export layer aligning with upstream opencode V2 session architecture. +- DocumentStore concurrency-safe durable body (F30-1): atomic CAS writes, version conflicts, recovery. +- Plan single source of truth (I33-1): goal path and tool path write one plan document; DocumentStore is the authority. +- safeGit hardening (I33-5): `--no-ext-diff --no-textconv` added to all read-path git calls; hook execution disabled via `core.hooksPath=/dev/null`; clean/smudge/process filters never invoked on read-only paths. + +## V4.1 - Steering + plan hot-edit + +- Steering foundation (S1.1): absorb mid-turn user input at the next turn boundary without aborting the current turn. +- Goal plan hot-edit (S1.2): update plan steps while a goal loop is running; orphan-doc bug fixed (upsert-by-description → updateWithProvenance by-id). +- Cache regression fix: DeepAgent gateway no longer bakes per-round volatile state (round number, budget, previous results) into the system prefix, preserving prompt cache across intra-turn calls. +- Subagent panel and session fork lineage: forks use `metadata.forkedFrom`; depth cap 3; derived-from banner and folder-tree nesting. + +## V4.0 - Event-driven paradigm + +- Event-driven Agent OS: durable events, priority routing, backpressure, worker claims, leases, handoffs, retries, dead-letter recovery. +- Consumer-driven goals: `goal.tick.requested` claims and executes one idempotent tick, records facts, schedules next tick when goal remains eligible. +- V4.0-beta closeout: producer-starvation fix, security fails-closed, half-wired consumers wired. Autonomous path live in production. +- V4.0.1 long-task design: soft-landing compression (P0), World State responsibility separation (P1), budget hot-swap without restart (P2), idempotent + per-model output (P3). Four feature flags. Fully verified. +- Plan gate P0+P1: plan-stale signals all degrade to warn; U9 per-step binding retains hard block with grace release. +- CLI ↔ GUI parity: full legacy server surface mounted on new CLI daemon; sessionClient wrapper seam. +- Config data-root unification: global config moved from `~/.config/deepagent-code` to `~/.deepagent/code/config.jsonc` (claude/codex style). +- Zero-config provider: add third-party provider with URL + key; protocol auto-detect; model discovery from `/models`. + +## V3.9 - Repo/Wiki + Expert Panel + Goal Loop + +- Repo and Wiki integration: session archive, wiki-backed knowledge, cross-session search. +- Expert Panel: chat-button convenes a panel of domain experts; `panel.consult` tool. +- Goal Loop: `goal_driver.ts` drives multi-step autonomous goals; goal-tick event pipeline. +- AST code-graph: tree-sitter based symbol graph for semantic navigation. +- Subagent plan permissions: plan-write capability gating per subagent. +- Adversarial review wave: 20-file fix commit; flag-gating, budget-ceiling, anonymization, leaf-calls, sealed-leak all fixed; Arbiter + security boundary verified. +- Cache hit regression root cause: volatile per-round state in system prefix → fixed by moving it out of the cached prefix. + +## V3.8 - V4.0 pre-release foundation + +- Session-internal scheduler: all sub-agent execution driven via `SessionPrompt.Service`; no-op stack replaced. +- Context wiring: full context assembly pipeline connected end-to-end. +- Sub-agent strength levels: permission presets per agent mode. +- Mode redesign: codex-aligned auto/loop/design modes; flag kill-switch; permission presets. +- Server mode connection: desktop→Server Edition gateway; wire contract; client code map. + +## V3.5 - M-CRED secure secret storage + +- Secrets stored in OS-backed secret storage: macOS Keychain (production), Linux Secret Service and Windows Credential Manager stubs with 0600-file fallback. +- MCP credential values no longer persist in plain-text configuration; only variable names or references travel through config. +- Credential migration: existing stored secrets migrated to the new store on first launch. +- PTY and terminal fixes: stale-worktree redirect, terminal split circular-tree bug. +- Archived sessions: restore, unarchive, delete operations. +- Stale worktree redirect: same-repo clones share one project row; `fromDirectory` now returns live clone dir. + ## V3.4.1 - Public release hardening - Switch project license to AGPL-3.0-or-later. diff --git a/README.md b/README.md index 3e8d672f..fb0e6be3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Enterprise

-

Desktop 1.4.1 · DeepAgent Core V4.1

+

Desktop 1.4.2 · DeepAgent Core V4.0.4

--- @@ -91,22 +91,25 @@ For high-risk decisions, convene an **Expert Panel**. Correctness, security, per Project IM brings people and agents into the same thread. Mention an agent to start a scoped run with project context, stream its progress, inspect its artifacts, and keep the answer attached to the conversation that requested it. -## DeepAgent Core V4.1 +## DeepAgent Core V4.0.4 -V4.1 brings the complete DeepAgent control plane together: +V4.0.4 closes production contract gaps while keeping the current turn engine stable: -- **Durable Session V2:** prompt admission is persisted before execution; exact retries do not duplicate user intent; same-session wakes coalesce safely. -- **One provider-turn contract:** native and AI SDK providers share the same budget, permission, artifact, audit, learning, and close lifecycle. -- **Single durable truth:** DocumentStore owns documents, plans, learning candidates, governance state, and version conflicts through atomic, recoverable writes. -- **Event-driven Agent OS:** durable events, priority routing, backpressure, worker claims, leases, handoffs, retries, dead-letter recovery, and distributed placement coordinate autonomous work. -- **Consumer-driven goals:** `goal.tick.requested` claims and executes one idempotent tick, records facts, and schedules the next tick only when the durable goal remains eligible. -- **Human oversight:** approval queues, trace correlation, takeover, rollback, Wiki archives, notifications, and organization/workspace isolation remain part of the execution path. -- **Secure integrations:** MCP credentials use environment references or native OS secret storage; catalog risk, runtime permissions, trusted sources, and tool capability checks fail closed. +- **Single durable truth:** DocumentStore uses atomic, recoverable writes for documents, plans, learning candidates, governance state, and version conflicts. +- **Isolated subagents:** write-capable subagents use dedicated worktrees by default and return their changes to the parent workspace through a bounded, conflict-aware path. +- **Reliable event delivery:** the Event Bus has a transport seam, durable consumer offsets, offline catch-up, real priority ordering, and observable queue depth. +- **Governed learning and goals:** knowledge promotion is tied to review evidence and ship-gate snapshots; event-driven goal ticks remain idempotent and respect quiet hours. +- **Secure integrations:** MCP credentials use environment references or native OS secret storage on macOS, Linux, and Windows; capability and source checks fail closed. +- **Publishing truth:** installation, CLI examples, release metadata, public domains, and supported-version documentation match the product that is actually shipped. ## Installation +> **Note:** The `deepagent-code` npm package is not yet publicly published. +> Install via the desktop app or the install script below. + ```bash -npm install -g deepagent-code +# Install script (macOS / Linux) +curl -fsSL https://deepagent.ltd/install | bash ``` Then run: @@ -183,7 +186,7 @@ reference (base URL overrides, headers, per-model config, gateways). Start the agent and give it a task: ```bash -deepagent-code "add rate limiting to /api/users endpoint" +deepagent-code run "add rate limiting to /api/users endpoint" ``` The agent will: diff --git a/README.zh.md b/README.zh.md index 15a1714d..e6dde70a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,7 @@ Enterprise 版本

-

桌面版 1.4.1 · DeepAgent Core V4.1

+

桌面版 1.4.2 · DeepAgent Core V4.0.4

--- @@ -91,22 +91,25 @@ DeepAgent 可以把独立工作拆分给数量有界、相互隔离的 Worker。 项目 IM 把团队成员和智能体放进同一条讨论。@ 某个智能体即可启动有明确作用域的运行,使用项目上下文、流式展示进度、关联执行工件,并把答案留在发起任务的对话里。 -## DeepAgent Core V4.1 +## DeepAgent Core V4.0.4 -V4.1 把完整的 DeepAgent 控制平面汇聚在一起: +V4.0.4 在保持当前 turn 引擎稳定的前提下,关闭生产合同缺口: -- **持久 Session V2:** prompt 先持久准入、再调度执行;精确重试不会复制用户意图;同一 Session 的唤醒会安全合并。 -- **统一供应商轮次合同:** native 与 AI SDK provider 共享预算、权限、工件、审计、学习和关闭生命周期。 - **单一持久真相:** DocumentStore 通过原子、可恢复写入统一管理文档、计划、学习候选、治理状态和版本冲突。 -- **事件驱动 Agent OS:** 持久事件、优先级路由、回压、Worker claim、租约、handoff、重试、死信恢复与分布式 placement 协调自主工作。 -- **消费者驱动 Goal:** `goal.tick.requested` 每次认领并执行一个幂等 tick,记录事实,并只在持久目标仍满足条件时调度下一 tick。 -- **人类监督:** 审批队列、全链路 trace、接管、回滚、Wiki 档案、通知,以及组织和 workspace 隔离始终位于执行路径上。 -- **安全集成:** MCP 凭据使用环境变量引用或原生操作系统 secret storage;目录风险、运行时权限、可信来源和工具 capability 逐层失败关闭。 +- **隔离子智能体:** 具备写权限的子智能体默认使用独立 worktree,并通过有界、可感知冲突的路径把改动回传到父工作区。 +- **可靠事件投递:** Event Bus 提供可替换 transport、持久 consumer offset、离线补投、真实优先级排序和可观测队列深度。 +- **受治理的学习与目标:** 知识晋升关联审阅证据与 ship-gate snapshot;事件驱动 goal tick 保持幂等并遵守 quiet hours。 +- **安全集成:** MCP 凭据使用环境变量引用或 macOS、Linux、Windows 原生 secret storage;capability 与来源检查逐层失败关闭。 +- **发布真实性:** 安装方式、CLI 示例、发布元数据、公开域名和支持版本文档与实际交付产品一致。 ## 安装 +> **说明:** `deepagent-code` npm 包尚未公开发布。 +> 请通过桌面应用或下面的安装脚本安装。 + ```bash -npm install -g deepagent-code +# 安装脚本(macOS / Linux) +curl -fsSL https://deepagent.ltd/install | bash ``` 然后运行: @@ -178,7 +181,7 @@ deepagent auth list 启动智能体并交给它一个任务: ```bash -deepagent-code "为 /api/users 端点添加限流" +deepagent-code run "为 /api/users 端点添加限流" ``` 智能体将会: diff --git a/SECURITY.md b/SECURITY.md index dc4bf2ae..ee97178b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ Do not include live secrets in reports. Use redacted examples or synthetic crede ## Supported versions -Before the first public stable tag, the supported line is the current `main` branch and the latest published pre-release. Security fixes may be released as patch versions. +The active development line is the `dev` branch. The supported release is the latest published version on the `core-v4.0-beta` branch and the desktop app release derived from it. Security fixes are applied to the active line and backported to the latest release where feasible. ## Source availability @@ -20,13 +20,13 @@ Preset MCP servers are opt-in. The preset catalog records intended risk tiers, b Read-only database presets are intended to use restricted server modes and SQL guardrails. Guardrails are defense in depth, not a substitute for least-privilege database users. -## Known limitation: preset MCP credentials in V3.4.1 +## MCP credential security (V4.0+) -When enabling preset MCP servers that require credentials, credential values may currently be persisted in local configuration. Until V3.5 M-CRED lands: +As of V4.0, MCP server credentials are stored in OS-backed secret storage where available (macOS Keychain; Linux and Windows fall back to a 0600 file). Credential values are not persisted in plain-text configuration. Only variable names or references travel through config files; values are resolved at runtime. + +If you are running a version older than V4.0: - Do not commit DeepAgent Code configuration files containing secrets. - Prefer environment-variable indirection where a server supports it. - Use least-privilege tokens and database users. - Rotate credentials if they were accidentally committed or shared. - -The planned V3.5 M-CRED work stores secrets in OS-backed secret storage where available (macOS Keychain, Windows Credential Manager, Linux Secret Service), passes only variable names or references through configuration, and resolves values at runtime. diff --git a/bun.lock b/bun.lock index 82ed0bd7..ed42c9ff 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@deepagent-code/app", - "version": "1.4.0", + "version": "1.0.0-beta", "dependencies": { "@codemirror/autocomplete": "6", "@codemirror/commands": "6", @@ -109,15 +109,20 @@ "lildax": "./bin/lildax.cjs", }, "dependencies": { + "@agentclientprotocol/sdk": "^0.21.0", "@deepagent-code/core": "workspace:*", "@deepagent-code/sdk": "workspace:*", "@deepagent-code/server": "workspace:*", "@deepagent-code/tui": "workspace:*", "@effect/platform-node": "catalog:", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", + "deepagent-code": "workspace:*", + "drizzle-orm": "catalog:", "effect": "catalog:", + "jsonc-parser": "^3.3.1", "solid-js": "catalog:", }, "devDependencies": { @@ -344,7 +349,7 @@ }, "packages/desktop": { "name": "@deepagent-code/desktop", - "version": "1.4.0", + "version": "1.4.2", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", diff --git a/design/README.md b/design/README.md index bd41fcfb..f2239621 100644 --- a/design/README.md +++ b/design/README.md @@ -1,266 +1,123 @@ -# DeepAgent Code Architecture & Design +# DeepAgent Code — Architecture & Design -> Public architecture baseline for DeepAgent Core V4.1. +> **Public design overview for DeepAgent Core V4.0.4 / Desktop 1.4.2.** Internal implementation details and roadmap documents live in the private `docs/` tree and are intentionally not version-controlled. -DeepAgent Code is a document-centered, event-driven AI coding system. It combines a coding-agent runtime with a durable control plane that owns context, planning, learning, collaboration, safety, and human oversight. +--- -The architecture is designed around one requirement: a long-running agent must remain correct and governable after many model turns, tool calls, user interventions, process restarts, and worker handoffs. +## What is DeepAgent Code? -## Design Principles +DeepAgent Code is an AI coding agent that adds a **control plane** on top of the [opencode](https://github.com/sst/opencode) runtime. It keeps the proven opencode foundations (runtime, tool, MCP, session, provider stack) and layers in: -### One durable truth per concept +- **Durable document and project memory** — atomic, recoverable storage with retrieval gates, provenance, governance, and conflict detection +- **Connected context** — selective, evidence-backed assembly across code, knowledge, project memory, and execution documents +- **Plans and long-running goals** — structured plans, stale-state detection, validation evidence, bounded retries, and human control +- **Event-driven coordination** — durable delivery, priority routing, offline catch-up, idempotent goal ticks, and observable queue state +- **Isolated agent collaboration** — bounded subagents, worktree isolation for write-capable workers, and conflict-aware change return +- **AI IDE microservice** — LSP-backed semantic code navigation via `code_intel` +- **Secure MCP catalog** — curated integrations, derived safety tiers, environment references, and native OS secret storage -Sessions, inputs, plans, documents, goals, events, approvals, and learning decisions each have one authoritative durable representation. In-memory state is a cache or an ownership hint, never a competing source of truth. +--- -### Admission is separate from execution +## Architectural Principles -A user instruction is durably admitted before execution is scheduled. A successful API response therefore means the instruction is recorded, not merely present in a process-local queue. +### 1. Enhance, don't replace -### One provider-turn contract +DeepAgent is built **on top of** the opencode agent/runtime/session/tool/MCP stack. V4.0.4 strengthens the control plane without replacing the current turn engine, tool system, or provider layer. -Native and AI SDK providers pass through the same model-turn boundary. Budget, permissions, tool policy, prompt assembly, artifacts, audit, learning, and close semantics cannot be bypassed by selecting another provider implementation. +### 2. One durable authority per concern -### Context is selected, not accumulated +Documents, plans, event delivery state, knowledge promotion, and goal progress each have one authoritative durable store. In-memory state may accelerate delivery, but it cannot become a second source of truth. -The model receives a bounded working set assembled from explicit Context Sources. Full tool output and durable history remain referenceable artifacts; only admitted summaries, evidence, and snippets enter the active context. +### 3. Full tool output does not enter context -### Safety fails closed +Per the deterministic task control contract: raw LSP results, diagnostic dumps, and capability indexes are written to **evidence artifacts** (ref-linked, tool-only visibility). Only summaries and `file:line` snippets appear in the model context. -External events, credentials, tools, paths, autonomy, worker placement, and outbound messages are checked at their execution boundaries. Missing identity, trust, capability, or approval never widens access. +### 4. Fail-closed on safety -### Humans can always intervene +MCP catalog entries default to **not connected** (zero startup overhead). Dangerous write operations (force-push, DROP, file delete) require explicit approval. Read-only DB connections enforce restricted-mode at the server level. -Steering, plan editing, approval, pause, resume, takeover, rollback, and review are part of the runtime contract. They are not dashboard-only controls layered over an autonomous black box. +### 5. Keep execution boundaries explicit -## System Map +Write-capable subagents run in isolated worktrees by default. Event consumers claim durable work with idempotency and retry boundaries. Users retain explicit paths to approve, steer, pause, resume, take over, or roll back long-running work. -```text -┌─────────────────────────────────────────────────────────────────────┐ -│ Experience │ -│ Desktop · Web · TUI · IM · Repo & Wiki · Expert Panel · Oversight │ -└──────────────────────────────┬──────────────────────────────────────┘ - │ -┌──────────────────────────────▼──────────────────────────────────────┐ -│ Durable Session Runtime │ -│ Session V2 · System Context · Context Epoch · Steering · Queue │ -│ Prompt cache policy · Provider-turn lifecycle · Tool materialization│ -└──────────────────────────────┬──────────────────────────────────────┘ - │ -┌──────────────────────────────▼──────────────────────────────────────┐ -│ DeepAgent Control Plane │ -│ PlanController · GoalController · Context Graph · Learning │ -│ Event Router · Scheduler · Worker Pool · Handoff · Security gates │ -└──────────────────────────────┬──────────────────────────────────────┘ - │ -┌──────────────────────────────▼──────────────────────────────────────┐ -│ Durable State │ -│ DocumentStore · Session/Event database · Event Bus · Audit/Artifacts│ -└──────────────────────────────┬──────────────────────────────────────┘ - │ -┌──────────────────────────────▼──────────────────────────────────────┐ -│ Execution Services │ -│ Provider · Tool · LSP · MCP · Git/Worktree · Debug · Profile │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## Session V2 - -Session V2 separates durable prompt admission from model execution. - -### Prompt admission - -- Each prompt creates one durable `session_input` before scheduling work. -- Reusing a Session ID adopts that Session rather than creating a parallel execution entity. -- Reusing a prompt message ID is accepted only for an exact retry with the same Session, content, and delivery mode. -- Conflicting ID reuse fails instead of silently reconciling different user intent. -- A prompt can be admitted without waking execution when the caller requests admit-only behavior. - -### Delivery vocabulary - -| Delivery | Meaning | -|---|---| -| normal turn | Start a normal activity when the Session is idle | -| `steer` | Add guidance to the active activity at the next safe provider-turn boundary | -| `goal_steer` | Add guidance to the next Goal tick | -| `queue` | Open a future FIFO activity after the active activity settles | -| interrupt | Target the active process-local ownership chain immediately | - -Steering never aborts an in-flight tool or stream. The input is persisted first, absorbed in stable order, and materialized into history exactly once. - -### Execution ownership - -`SessionExecution` is process-global and keyed by Session ID. A drain discovers placement from durable Session location only when it starts. SessionRunner, model resolution, tools, permissions, and filesystem services remain Location-scoped. - -Same-Session resumes join one coordinator; advisory wakes coalesce; different Sessions can run concurrently. Every provider turn performs one explicit `llm.stream(request)` call and reloads projected history before durable continuation. - -### Prompt cache invariant - -The system prompt is split into a byte-stable prefix and one append-only volatile tail: - -- Agent instructions, stable policy, and System Context baseline stay in the prefix. -- Round state, budgets, plan snapshots, prior results, fan-out decisions, and steering stay in the tail. -- OpenAI-compatible providers use a stable Session cache key; providers with cache markers use their protocol-native breakpoint. -- Prefix hash and cache outcomes distinguish normal compaction misses from accidental prefix drift. - -## Document System - -DocumentStore is the durable body for DeepAgent state. Documents carry: - -- a stable ID and monotonic version; -- type, scope, status, domain, tags, and description; -- provenance and evidence references; -- confidence, sensitivity, and approval risk; -- typed links such as `supports`, `blocks`, `conflicts`, `validates`, `supersedes`, `contains`, `imports`, and `calls`. - -Writes use atomic replacement and conflict detection. Concurrent handles observe one authority, process-level writers coordinate through lock/CAS semantics, and migrations are incremental, restartable, and integrity-checked. - -### Scope - -| Scope | Purpose | -|---|---| -| `session-private` | Current conversation and run-local state | -| `project-shared` | Knowledge and decisions shared by one project | -| `user-global` | Cross-project preferences and explicitly promoted knowledge | -| `public-system` | Built-in skills and domain-pack documents | -| `sealed` | Audit/evaluator material that cannot enter model context | - -Plans, run context, worklogs, designs, diagnoses, evaluations, knowledge, memory, strategies, methodologies, skills, and failure dossiers share this document algebra instead of maintaining separate storage models. - -## System Context and the Four Graphs - -The Context System connects four projections over the same durable knowledge surface: - -1. **Code:** files, symbols, imports, calls, definitions, references, diagnostics. -2. **Knowledge:** facts, strategies, methodologies, skills, and failure dossiers. -3. **Memory:** decisions, constraints, project conventions, environment facts, and handoffs. -4. **Documents:** plans, designs, run context, evidence, worklogs, and evaluations. - -Context Sources produce typed observations from their domains. Session-owned selection applies budget, relevance, scope, sensitivity, evidence strength, conflict, and snapshot rules. A Context Epoch records the selected baseline so a provider turn is reproducible and observable. - -The Event Router can attach a context strategy to an event. SessionRunner executes that strategy in the target Location, records query and admission decisions in trace, and degrades safely when an optional source is unavailable. - -Compaction preserves a stable structure: goal, constraints, completed and active work, blockers, decisions, next steps, critical facts/open questions, and relevant files. Durable references remain outside the prose summary and can be reloaded when needed. - -## Planning and Goal Execution +--- -### Plan authority +## Component Map -A structural plan is a versioned DocumentStore document. Session hot state keeps only its plan pointer/version and stale latch. The model plan tool, human plan editor, Goal worker, UI, Grader, and archive all read and update the same plan through version-aware writes. - -The runtime derives plan staleness from facts it already observes: - -- the user adds new guidance; -- a tool or execution step fails; -- validation fails; -- repeated work makes no progress; -- the active domain-pack snapshot changes. - -Read and diagnosis tools remain available while stale. Mutating tools require the plan to be synchronized, with bounded anti-deadlock behavior. - -### Goal Loop - -A Goal has objective completion criteria, a plan, a durable run context, a budget ledger, and a bounded controller. Each tick: - -1. claims the expected durable Goal/plan version; -2. applies pending user plan edits and steering; -3. executes one coherent step; -4. records tools, tokens, cost, time, evidence, and progress; -5. evaluates objective criteria and stall state; -6. emits facts and schedules the next tick only when eligible. - -`goal.tick.requested` is a durable command, not a post-hoc trace marker. Duplicate delivery cannot repeat provider or tool side effects. Pause, stop, takeover, hard limits, quiet hours, needs-human, and terminal state all stop self-continuation. - -Hot plan edits preserve reusable step IDs and completed evidence, increment the plan version, and reset progress/stall baselines without interrupting the in-flight tick. - -## Event-Driven Agent Runtime - -The Event Bus provides persist-before-dispatch delivery, idempotency, priority, retry, acknowledgement, dead-letter handling, retention, replay, and correlation. Local deployments use the embedded backend; distributed deployments use Redis Streams or Kafka through the same backend contract. - -The Router combines event type, trusted source, actor identity, Agent trigger/capability metadata, autonomy ceiling, approval intent, context strategy, deduplication, priority, and workspace backpressure into one traceable route decision. - -The Worker Pool owns bounded concurrency, placement, claims, leases, renewal, recovery, and handoff. Independent DAG nodes run concurrently; dependency edges remain ordered. File and symbol claims are shared across Workers so conflicting writes cannot run together. - -Write-capable Agents use isolated worktrees by default. Parent Agents receive bounded summaries, status, artifact/session references, and necessary diffs; complete child transcripts remain in their own Sessions. - -## Human Collaboration and Oversight - -### Repo & Wiki - -Repo & Wiki is a human-facing projection, never a second source of truth. It exposes document and code navigation, full-text search, docs-to-code links, knowledge governance, and execution archives. Organization and workspace identity are enforced on query, index, archive, and promotion paths. - -### Expert Panel - -Panelists receive the same frozen question and evidence under differentiated lenses. Anonymous multi-round debate avoids identity anchoring. A deterministic Arbiter applies quorum, preserves minority opinions, and routes unsafe ambiguity to the Approval Queue. Distributed panelists run through the Worker Pool. - -### IM and proactive delivery - -Project IM supports groups, direct conversations, threads, search, attachments, agent mentions, progress streaming, and permission revalidation when project bindings change. Event-driven notifications and digests pass through content safety, path ACL, external-link, rate, and quiet-hours policies before delivery. - -### Oversight - -Correlation IDs connect event, route, context, worker claim, Session, provider turn, tool, artifact, approval, and outbound action. Operators can inspect Approval Queue items, dead letters, budgets, conflicts, takeovers, rollbacks, and final delivery without reconstructing state from logs. - -## Learning and Knowledge Governance - -Learning runs outside the interactive turn on idle, pause, project switch, and Session finalization triggers. Candidates enter the same DocumentStore lifecycle used by human governance. - -- Low-risk project candidates can pass deterministic auto-review. -- Medium-risk/global candidates use an isolated blank-thread reviewer with no Session history. -- Sensitive, strategic, regulated, or irreversible candidates require human review. -- Rejection status, reason, and fingerprint remain authoritative in DocumentStore; auxiliary indexes are rebuildable projections. -- Promotion changes the same document's status/version instead of copying it into a second identity. -- Released retrieval sets name a snapshot and carry evaluation matrix, baseline, repeats, and ablation verdict. +``` +┌─────────────────────────────────────────────────────────────┐ +│ DeepAgent Control Plane │ +│ │ +│ DocumentStore ─ Plan/Goal ─ Knowledge Governance │ +│ │ │ │ │ +│ Context Graph ─ Event Bus ─ Oversight and Audit │ +│ │ │ │ │ +│ ┌────▼──────────────▼────────────────▼──────────────────┐ │ +│ │ Agent Gateway: budget · permission · evidence · policy│ │ +│ └────┬───────────────────────────────────────────────────┘ │ +└───────│─────────────────────────────────────────────────────┘ + │ +┌───────▼────────────────────────────────────────────────────┐ +│ opencode Foundation │ +│ Session · Provider · Tool Registry · LSP · MCP │ +└────────────────────────────────────────────────────────────┘ + │ +┌───────▼────────────────────────────────────────────────────┐ +│ Execution Boundaries │ +│ isolated subagents · worktrees · event consumers · tools │ +└────────────────────────────────────────────────────────────┘ +``` -A failed release gate restores the previous knowledge snapshot. Selected and rejected refs remain reproducible in run artifacts. +--- -## Code Intelligence +## code_intel — AI IDE Microservice -The AI IDE surface combines: +The `code_intel` tool wraps the LSP stack as a **symbol-driven semantic API**. The agent specifies a symbol name and an intent; `code_intel` resolves line/column coordinates internally and returns `file:line` + code snippets. -- semantic symbol lookup and intent-oriented `code_intel` queries; -- definitions, references, calls, imports, type hierarchy, diagnostics, and rename preview; -- unsaved-buffer `textDocument/didOpen`, `didChange`, `didSave`, and `didClose` synchronization; -- incremental code indexing with exact mtime I/O hints and content-SHA correctness authority; -- deterministic fallback to repository search/read when a language server is unavailable. +```typescript +code_intel({ symbol: "AgentGateway.open", intent: "overview" }) +// → definition + type + references + callers + callees + doc summary +// full detail → evidence artifact (ref only in context) +``` -Full LSP output stays in evidence artifacts. The model receives bounded summaries and precise source references. +Supported intents: `definition · references · implementations · type · calls_in · calls_out · supertypes · subtypes · type_hints · hover · rename_preview · quick_fix · outline · diagnostics · overview` -## MCP and Credential Security +Graceful degradation: if no LSP server is configured for the file type, returns a hint to use `grep/read`. Capability only grows, never drops. -MCP servers can be added from the curated catalog or configured manually through Desktop, HTTP, or CLI. Catalog risk tiers are derived from trusted templates rather than mutable user configuration. Servers default to disconnected; writes, external fetches, and privileged actions pass through runtime permission gates. +--- -Credentials use indirection rather than plaintext project configuration: +## MCP Catalog — Safety Model -- `${VAR}` and `${VAR:-default}` resolve at connection time; -- `secret://` handles resolve through macOS Keychain, Linux Secret Service, or Windows Credential Manager/DPAPI; -- environments without a native keyring require explicit approval for an audited local fallback outside the project repository; -- logs, artifacts, outbound messages, and config views redact resolved values. +Each catalog entry carries a **risk tier** derived at load time from the catalog template. The tier is **not user-writable** — it is computed from the entry definition, preventing config-injection attacks. -## Security Boundaries +| Tier | Examples | Default behavior | +|------|----------|-----------------| +| `read_only` | postgres-readonly | All ops auto-allowed | +| `write_guarded` | filesystem, github, git | Write ops require explicit approval | +| `external_fetch` | fetch, browser (Playwright) | External requests require explicit approval | -Autonomous execution passes four independent gates: +**Credentials** are declared by key name in the catalog template (`CredentialSpec`). Configuration stores environment references or `secret://` handles instead of plaintext values. Handles resolve at connection time through macOS Keychain, Linux Secret Service (`libsecret`), or Windows DPAPI-backed credential storage. -1. the event source is trusted for the workspace; -2. the actor has permission for the project and resource; -3. the Agent descriptor permits the trigger, capability, and autonomy level; -4. the runtime permits the concrete tool, path, network target, and side effect. +--- -Additional controls include durable budgets, rate limits, quiet hours, secret/path/link filtering, worktree isolation, hardened read-only Git, human approval, takeover, rollback, and complete audit correlation. +## Security Model Summary -## Repository Map +| Mechanism | Status | +|-----------|--------| +| MCP risk tier — catalog-derived, not config-injectable | Available | +| MCP catalog defaults to not connected | Available | +| Dangerous writes: approval gate (`ctx.ask`) | Available | +| Read-only DB: restricted mode enforced at server | Available | +| Credential indirection (`${VAR}` / `secret://`) | Available | +| Native secret storage (macOS / Linux / Windows) | Available in V4.0.4 | +| Subagent write isolation and conflict-aware return | Available in V4.0.4 | -| Area | Path | -|---|---| -| Core Session, documents, context, event, and policy algebra | `packages/core/src/` | -| CLI/server runtime, tools, Goal and event wiring | `packages/deepagent-code/src/` | -| Desktop/Web application UI | `packages/app/src/` | -| Electron host and isolated browser views | `packages/desktop/src/` | -| Provider abstraction | `packages/llm/` | -| Domain knowledge packs | `packages/domain-packs/` | -| Generated JavaScript SDK | `packages/sdk/js/` | +--- -## License and Source +## License -DeepAgent Code is licensed under **AGPL-3.0-or-later**. The canonical repository is [github.com/deepagent-ltd/deepagent-code](https://github.com/deepagent-ltd/deepagent-code). +DeepAgent Code is licensed under **AGPL-3.0-or-later**. +Source code: [github.com/deepagent-ltd/deepagent-code](https://github.com/deepagent-ltd/deepagent-code) -The project is derived from [opencode](https://github.com/sst/opencode) under the MIT License. Upstream attribution is preserved in [NOTICE](../NOTICE). +DeepAgent Code is derived from [opencode](https://github.com/sst/opencode) (MIT). +See `NOTICE` in the repository root for the full upstream attribution. diff --git a/github/README.md b/github/README.md index 86f4d99f..9d75e57b 100644 --- a/github/README.md +++ b/github/README.md @@ -88,7 +88,7 @@ This will walk you through installing the GitHub app, creating the workflow, and persist-credentials: false - name: Run deepagent-code - uses: lessweb/deepagent-code/github@latest + uses: deepagent-ltd/deepagent-code/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -101,7 +101,7 @@ This will walk you through installing the GitHub app, creating the workflow, and ## Support -This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/lessweb/deepagent-code/issues. +This is an early release. If you encounter issues or have feedback, please create an issue at https://github.com/deepagent-ltd/deepagent-code/issues. ## Development diff --git a/github/action.yml b/github/action.yml index 47f9d13d..249fd2a9 100644 --- a/github/action.yml +++ b/github/action.yml @@ -45,7 +45,7 @@ runs: id: version shell: bash run: | - VERSION=$(curl -sf https://api.github.com/repos/lessweb/deepagent-code/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) + VERSION=$(curl -sf https://api.github.com/repos/deepagent-ltd/deepagent-code/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT - name: Cache deepagent-code diff --git a/package.json b/package.json index 6e0cdade..4d838030 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "prepare": "husky", "random": "echo 'Random script'", "sso": "aws sso login --sso-session=deepagent-code --no-browser", - "test": "echo 'do not run tests from root' && exit 1" + "test": "bun run --cwd packages/app test && bun run --cwd packages/ui test" }, "workspaces": { "packages": [ diff --git a/packages/app/README.md b/packages/app/README.md index 9040c386..1bf37cbd 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -1,33 +1,32 @@ -## Usage +# @deepagent-code/app — Desktop Application -Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`. +SolidJS front-end shell for the DeepAgent Code desktop app (Electron/Tauri). -This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template. +## Stack -```bash -$ npm install # or pnpm install or yarn install -``` - -### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) - -## Available Scripts +- **UI:** SolidJS + Vite (Bun) +- **Backend:** `deepagent-code` package spawned as a local server or connected via the server-mode gateway +- **Build output:** `dist/` — consumed by the Electron/Tauri packager in CI -In the project directory, you can run: +## Development -### `npm run dev` or `npm start` +```bash +# from repo root +bun install +bun run dev # hot-reload dev server +``` -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. +## Building -The page will reload if you make edits.
+```bash +bun run build # production bundle → dist/ +``` -### `npm run build` +Desktop releases are produced by the `desktop-build` CI workflow, which reads the version from this `package.json` and tags the release `app-v{version}-main.{run_number}`. -Builds the app for production to the `dist` folder.
-It correctly bundles Solid in production mode and optimizes the build for the best performance. +## Configuration -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! +Provider credentials and agent settings live in `~/.deepagent/code/`. See the root README for the full reference. ## E2E Testing diff --git a/packages/app/e2e/regression/panel-hosts.spec.ts b/packages/app/e2e/regression/panel-hosts.spec.ts new file mode 100644 index 00000000..cadb924f --- /dev/null +++ b/packages/app/e2e/regression/panel-hosts.spec.ts @@ -0,0 +1,249 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@deepagent-code/core/util/encode" +import { mockDeepAgentCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/DeepAgent Code/PanelRegression" +const projectID = "proj_panel_regression" +const sessionID = "ses_panel_regression" + +async function openSession(page: import("@playwright/test").Page) { + let diagnosticsRequests = 0 + await mockDeepAgentCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "panel-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "deepagent-code", + name: "DeepAgent Code", + models: { model: { id: "model", name: "Model", limit: { context: 200_000 } } }, + }, + ], + connected: ["deepagent-code"], + default: { providerID: "deepagent-code", modelID: "model" }, + }, + sessions: [ + { + id: sessionID, + slug: "panel-regression", + projectID, + directory, + title: "Panel regression", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/lsp/diagnostics**", async (route) => { + diagnosticsRequests++ + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + "C:/DeepAgent Code/PanelRegression/src/app.ts": [ + { + message: "Type mismatch", + severity: 1, + source: "ts", + code: 2322, + range: { start: { line: 4, character: 2 }, end: { line: 4, character: 8 } }, + }, + ], + "C:/DeepAgent Code/PanelRegression/src/index.ts": [ + { + message: "Unused value", + severity: 2, + source: "eslint", + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 6 } }, + }, + ], + }), + }) + }) + await page.addInitScript(() => + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })), + ) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectAppVisible(page.getByRole("button", { name: "Toggle bottom panel" })) + return { diagnosticsRequests: () => diagnosticsRequests } +} + +async function expectTerminalPaneInHost(page: import("@playwright/test").Page, host: "bottom" | "side") { + const target = page.locator(`[data-terminal-host="${host}"]`) + await expect(target).toBeVisible() + await expect(target.locator("[data-terminal-pane]")).toHaveCount(1) +} + +test("Bottom Panel, movable views, Problems, and mobile reachability", async ({ page }) => { + const runtime = await openSession(page) + + const bottomToggle = page.getByRole("button", { name: "Toggle bottom panel" }) + await bottomToggle.click() + const bottom = page.locator("#bottom-panel") + await expect(bottom).toBeVisible() + await expect(bottom.getByRole("tab", { name: "Terminal", exact: true })).toBeVisible() + await bottom.getByRole("tab", { name: "Terminal", exact: true }).click() + await expect(bottom.getByLabel("New terminal")).toBeVisible() + await expect(bottom.getByLabel("Split terminal")).toBeVisible() + await expectTerminalPaneInHost(page, "bottom") + await page.screenshot({ path: "e2e/test-results/panel-terminal-actions.png", fullPage: true }) + + await bottom.getByRole("tab", { name: "Problems", exact: true }).click() + await expect.poll(runtime.diagnosticsRequests).toBeGreaterThan(0) + await expect(bottom.getByText("Type mismatch")).toBeVisible() + await expect(bottom.getByText("Unused value")).toBeVisible() + await expect(page.locator("[data-terminal-pane]")).toHaveCount(0) + await page.screenshot({ path: "e2e/test-results/panel-bottom-problems.png", fullPage: true }) + + await bottom.getByRole("button", { name: "Move to Right Sidebar" }).click() + await expect(bottom.getByText("Type mismatch")).toBeHidden() + const side = page.locator("#review-panel") + await expect(side.getByText("Type mismatch")).toBeVisible() + await side.getByRole("button", { name: "Move to bottom dock" }).click() + await expect(bottom.getByText("Type mismatch")).toBeVisible() + await expect(page.locator("[data-terminal-pane]")).toHaveCount(0) + + await bottomToggle.click() + await expect(bottom).toBeHidden() + await page.getByRole("button", { name: "Panel Views" }).click() + const panelViewsMenu = page.locator("[data-panel-views-menu]") + await expect(panelViewsMenu.getByText("Panel Views", { exact: true })).toBeVisible() + await expect(panelViewsMenu.getByText("Terminal", { exact: true })).toBeVisible() + await expect(panelViewsMenu.getByText("Debug Console", { exact: true })).toBeVisible() + await expect(panelViewsMenu.getByText("Problems", { exact: true })).toBeVisible() + await expect(panelViewsMenu.getByRole("button", { name: "Problems Bottom Panel" })).toBeVisible() + await panelViewsMenu.screenshot({ path: "e2e/test-results/panel-views-menu.png" }) + await page.getByRole("button", { name: "Problems Bottom Panel" }).click() + await expect(bottom.getByText("Type mismatch")).toBeVisible() + + await page.getByRole("button", { name: "Panel Views" }).click() + await page.getByRole("button", { name: "Move to Right Sidebar: Problems" }).click() + await expect(side.getByText("Type mismatch")).toBeVisible() + await page.getByRole("button", { name: "Panel Views" }).click() + await page.getByRole("button", { name: "Move to Bottom Panel: Problems" }).click() + await expect(bottom.getByText("Type mismatch")).toBeVisible() + + await bottom.getByText("Type mismatch").click() + await expect(page.getByText("app.ts").first()).toBeVisible() + + for (const view of ["Debug Console", "Terminal"]) { + await bottom.getByRole("tab", { name: view, exact: true }).click() + await bottom.getByRole("button", { name: "Move to Right Sidebar" }).click() + await expect(side.getByText(view).first()).toBeVisible() + if (view === "Terminal") { + await expectTerminalPaneInHost(page, "side") + const actionBoxes = await Promise.all( + [ + side.getByLabel("Split terminal"), + side.getByLabel("New terminal"), + side.getByLabel("Move to bottom dock"), + side.getByRole("button", { name: "Close", exact: true }), + ].map(async (control) => { + await expect(control).toBeVisible() + return control.boundingBox() + }), + ) + const boxes = actionBoxes.filter((box): box is NonNullable => box !== null) + expect(boxes).toHaveLength(4) + expect(boxes.every((box) => Math.abs(box.y - boxes[0].y) <= 1)).toBe(true) + expect(boxes.every((box, index) => index === 0 || box.x > boxes[index - 1].x)).toBe(true) + await side.screenshot({ path: "e2e/test-results/panel-side-terminal-toolbar.png" }) + } + await side.getByRole("button", { name: "Move to bottom dock" }).click() + await expect(bottom.getByRole("tab", { name: view, exact: true })).toBeVisible() + if (view === "Terminal") { + await bottom.getByRole("tab", { name: "Terminal", exact: true }).click() + await expectTerminalPaneInHost(page, "bottom") + } + } + + await bottom.getByRole("tab", { name: "Problems", exact: true }).click() + for (const view of ["Terminal", "Debug Console", "Problems"]) { + await page.getByRole("button", { name: "Panel Views" }).click() + await page.getByRole("button", { name: `Move to Right Sidebar: ${view}` }).click() + } + const unavailableBottomToggle = page.getByRole("button", { name: "Move a Panel View to the Bottom Panel first." }) + await expect(unavailableBottomToggle).toBeDisabled() + await expect(bottom).toHaveCSS("height", "0px") + await page.getByRole("button", { name: "Panel Views" }).click() + await page.getByRole("button", { name: "Move to Bottom Panel: Problems" }).click() + await expect(bottom.getByText("Type mismatch")).toBeVisible() + + await page.setViewportSize({ width: 767, height: 900 }) + await expect(page.locator("#review-panel")).toHaveCount(0) + await expect(bottom.getByRole("button", { name: "Move to Right Sidebar" })).toHaveCount(0) + await page.getByRole("button", { name: "Panel Views" }).click() + await expect( + page.locator("[data-panel-views-menu]").getByRole("button", { name: /Move to Right Sidebar/ }), + ).toHaveCount(0) + await page.screenshot({ path: "e2e/test-results/panel-mobile.png", fullPage: true }) +}) + +test("Terminal keeps one visible host and supports tabs plus atomic splits", async ({ page }) => { + await page.setViewportSize({ width: 2048, height: 1000 }) + await openSession(page) + await page.getByRole("button", { name: "Toggle bottom panel" }).click() + const bottom = page.locator("#bottom-panel") + await bottom.getByRole("tab", { name: "Terminal", exact: true }).click() + await expectTerminalPaneInHost(page, "bottom") + await expect(page.locator("[data-terminal-pane]")).toHaveCount(1) + + const pane = bottom.locator("[data-terminal-pane]") + await expect(pane.getByRole("tab")).toHaveCount(1) + await bottom.getByLabel("New terminal").click() + await expect(pane.getByRole("tab")).toHaveCount(2) + await expect(bottom.locator('[data-terminal-pty-id="pty_test_2"]')).toBeVisible() + await pane.getByRole("tab", { name: "Terminal 1" }).click() + await expect(bottom.locator('[data-terminal-pty-id="pty_test_1"]')).toBeVisible() + await pane.getByRole("tab", { name: "Terminal 2" }).click() + await expect(bottom.locator('[data-terminal-pty-id="pty_test_2"]')).toBeVisible() + + const split = bottom.getByLabel("Split terminal") + await expect(split).toBeEnabled() + await split.click() + const panes = bottom.locator("[data-terminal-pane]") + await expect(panes).toHaveCount(2) + await expect(page.locator("[data-terminal-pane]")).toHaveCount(2) + await expect(panes.nth(0).getByRole("tab")).toHaveCount(2) + await expect(panes.nth(1).getByRole("tab")).toHaveCount(1) + await expect(bottom.locator("[data-terminal-pane] [role=tab]")).toHaveCount(3) + + await expect(split).toBeEnabled() + await split.click() + await expect(panes).toHaveCount(3) + await expect(panes.nth(0).getByRole("tab")).toHaveCount(2) + await expect(panes.nth(1).getByRole("tab")).toHaveCount(1) + await expect(panes.nth(2).getByRole("tab")).toHaveCount(1) + await expect(bottom.locator("[data-terminal-pane] [role=tab]")).toHaveCount(4) + + await expect(split).toBeEnabled() + await split.click() + await expect(panes).toHaveCount(4) + await expect(bottom.locator("[data-terminal-pane] [role=tab]")).toHaveCount(5) + for (const index of [0, 1, 2, 3]) { + await expect(panes.nth(index)).toBeVisible() + await expect(panes.nth(index).getByRole("tab").last()).toBeVisible() + await expect.poll(async () => (await panes.nth(index).boundingBox())?.height ?? 0).toBeGreaterThan(100) + } + const widths = await panes.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().width)) + expect(Math.max(...widths) - Math.min(...widths)).toBeLessThan(8) + + await bottom.getByRole("tab", { name: "Problems", exact: true }).click() + await expect(page.locator("[data-terminal-pane]")).toHaveCount(0) + await bottom.getByRole("tab", { name: "Terminal", exact: true }).click() + await expect(page.locator("[data-terminal-pane]")).toHaveCount(4) + + await bottom.getByRole("button", { name: "Move to Right Sidebar" }).click() + await expect(page.locator('[data-terminal-host="bottom"]')).toHaveCount(0) + await expect(page.locator('[data-terminal-host="side"] [data-terminal-pane]')).toHaveCount(4) + await expect(page.locator("[data-terminal-pane]")).toHaveCount(4) +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index b563daea..f0851056 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -22,6 +22,8 @@ export interface MockServerConfig { } export async function mockDeepAgentCodeServer(page: Page, config: MockServerConfig) { + let ptySequence = 0 + const ptys = new Map() const staticRoutes: Record = { "/provider": config.provider, "/path": { @@ -45,7 +47,30 @@ export async function mockDeepAgentCodeServer(page: Page, config: MockServerConf const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.()) - if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/global/health") return json(route, { healthy: true, version: "test", runtimeId: "runtime-test" }) + if (path === "/pty" && route.request().method() === "GET") return json(route, [...ptys.values()]) + if (path === "/pty" && route.request().method() === "POST") { + ptySequence += 1 + const body: unknown = route.request().postDataJSON() + const title = + body && typeof body === "object" && "title" in body && typeof body.title === "string" + ? body.title + : `Terminal ${ptySequence}` + const pty = { id: `pty_test_${ptySequence}`, title } + ptys.set(pty.id, pty) + return json(route, pty) + } + const ptyMatch = path.match(/^\/pty\/([^/]+)$/) + if (ptyMatch && route.request().method() === "GET") { + const pty = ptys.get(ptyMatch[1]) + return pty ? json(route, pty) : json(route, { message: "PTY session not found" }, undefined, 404) + } + if (ptyMatch && route.request().method() === "PUT") return json(route, ptys.get(ptyMatch[1]) ?? {}) + if (ptyMatch && route.request().method() === "DELETE") { + ptys.delete(ptyMatch[1]) + return json(route, true) + } + if (/^\/pty\/[^/]+\/connect-token$/.test(path)) return json(route, { ticket: "test-ticket" }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) if (path in staticRoutes) return json(route, staticRoutes[path]) @@ -70,9 +95,9 @@ export async function mockDeepAgentCodeServer(page: Page, config: MockServerConf }) } -function json(route: Route, body: unknown, headers?: Record) { +function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ - status: 200, + status, contentType: "application/json", headers: { "access-control-allow-origin": "*", diff --git a/packages/app/src/components/deepagent-settings-ux.test.ts b/packages/app/src/components/deepagent-settings-ux.test.ts index f2e218fc..870cab56 100644 --- a/packages/app/src/components/deepagent-settings-ux.test.ts +++ b/packages/app/src/components/deepagent-settings-ux.test.ts @@ -23,23 +23,9 @@ describe("DeepAgent settings UX", () => { expect(v2.indexOf('data-action="settings-deepagent-prompt-mode"')).toBeLessThan( v2.indexOf('data-action="settings-deepagent-intelligence-model"'), ) - }) - - test("moves the permission approval control out of settings into the composer toolbar", async () => { - const v2 = await readFile(path.join(here, "settings-v2/general.tsx"), "utf8") - const composer = await readFile(path.join(here, "prompt-input.tsx"), "utf8") - const control = await readFile(path.join(here, "deepagent/approval-control.tsx"), "utf8") - - // The auto-accept toggle no longer lives in settings… - expect(v2).not.toContain('data-action="settings-auto-accept-permissions"') - // …it is a composer control next to the agent selector, backed by directory-level auto-accept. - expect(composer).toContain("ApprovalControl") - expect(control).toContain('"data-action": "prompt-approval"') - // Tri-state Read-Only / Request / Full-Access selector backed by the permission context. - expect(control).toContain("setDirectoryApprovalMode") - expect(control).toContain("composer.approval.request") - expect(control).toContain("composer.approval.readOnly") - expect(control).toContain("composer.approval.fullAccess") + expect(v2.indexOf('data-action="settings-deepagent-intelligence-model"')).toBeLessThan( + v2.indexOf('data-action="settings-auto-accept-permissions"'), + ) }) test("routes the legacy settings dialog import to the unified settings page", async () => { diff --git a/packages/app/src/components/deepagent/approval-control.tsx b/packages/app/src/components/deepagent/approval-control.tsx deleted file mode 100644 index cabd2b57..00000000 --- a/packages/app/src/components/deepagent/approval-control.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { createMemo, type JSX } from "solid-js" -import { Select } from "@deepagent-code/ui/select" -import { useLanguage } from "@/context/language" -import { usePermission, type DirectoryApprovalMode } from "@/context/permission" - -/** - * Approval-mode control for the composer toolbar, next to the agent (build/plan) selector. - * - * Mirrors Codex's approval selector UX: it collapses the approval×sandbox space into three named - * presets. The button shows the CURRENT mode; clicking opens a small picker to switch: - * - "Read-Only" — the agent may read/search but write/edit/bash requests are auto-rejected. - * - "Request" (default) — normal permission flow; the agent asks before write/execute. - * - "Full-Access" — auto-approve everything (the old directory-level auto-accept). - * - * The mode is DIRECTORY-scoped (persists across sessions in the same workspace), backed by the - * permission context tri-state (directoryApprovalMode / setDirectoryApprovalMode). Full-Access maps - * onto the existing isAutoAcceptingDirectory / toggleAutoAcceptDirectory state the old settings - * toggle drove, now surfaced where the user acts. - */ - -type ApprovalMode = DirectoryApprovalMode - -export function ApprovalControl(props: { directory: string; triggerStyle?: JSX.CSSProperties; onAfter?: () => void }) { - const language = useLanguage() - const permission = usePermission() - - const current = createMemo(() => - props.directory ? permission.directoryApprovalMode(props.directory) : "request", - ) - - const options: ApprovalMode[] = ["read-only", "request", "full-access"] - const label = (mode: ApprovalMode) => { - switch (mode) { - case "read-only": - return language.t("composer.approval.readOnly") - case "full-access": - return language.t("composer.approval.fullAccess") - default: - return language.t("composer.approval.request") - } - } - - const onSelect = (mode: ApprovalMode | undefined) => { - if (!mode || !props.directory) return - if (mode === current()) return - permission.setDirectoryApprovalMode(props.directory, mode) - props.onAfter?.() - } - - return ( - - - +
+ Press Enter to + send, Shift+Enter{" "} + for new line
) diff --git a/packages/app/src/components/im/message-item.tsx b/packages/app/src/components/im/message-item.tsx index 117c2eb0..af532e10 100644 --- a/packages/app/src/components/im/message-item.tsx +++ b/packages/app/src/components/im/message-item.tsx @@ -9,8 +9,6 @@ interface MessageItemProps { agentStatus?: { agentID: string; status: string } /** Live reasoning/tool/text parts for the turn this message triggered. */ agentProgress?: AgentProgressPart[] - /** §B3 thread — open this message's reply chain. Hidden when undefined. */ - onOpenThread?: (message: LocalMessage) => void } export function MessageItem(props: MessageItemProps) { @@ -60,16 +58,6 @@ export function MessageItem(props: MessageItemProps) { - - - - ) diff --git a/packages/app/src/components/im/message-list.tsx b/packages/app/src/components/im/message-list.tsx index 051f8bdd..4bd3e5d5 100644 --- a/packages/app/src/components/im/message-list.tsx +++ b/packages/app/src/components/im/message-list.tsx @@ -6,8 +6,6 @@ interface MessageListProps { messages: LocalMessage[] agentStatuses: Map agentProgress: Map - // §B3 thread — when provided, each message shows a "Reply in thread" affordance. - onOpenThread?: (message: LocalMessage) => void } export function MessageList(props: MessageListProps) { @@ -41,7 +39,6 @@ export function MessageList(props: MessageListProps) { message={message} agentStatus={props.agentStatuses.get(message.id)} agentProgress={props.agentProgress.get(message.id)} - onOpenThread={props.onOpenThread} /> )} diff --git a/packages/app/src/components/im/message-search.tsx b/packages/app/src/components/im/message-search.tsx deleted file mode 100644 index 48fc3091..00000000 --- a/packages/app/src/components/im/message-search.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { createSignal, For, Show, type Component } from "solid-js" -import { Button } from "@deepagent-code/ui/button" -import { useIMClient } from "@/utils/im-client" -import type { IMMessage } from "./types" - -// V4.0 §B3 — full-text message search (FTS + metadata) across the caller's group memberships. -// Backed by `GET /api/v1/im/search?q=…`. Results are keyset-paginated (nextCursor). Clicking a result -// hands the group + message id back to the panel so it can open the conversation. -export const MessageSearch: Component<{ - onSelect?: (result: { groupID: string; messageID: string }) => void -}> = (props) => { - const client = useIMClient() - const [query, setQuery] = createSignal("") - const [results, setResults] = createSignal([]) - const [cursor, setCursor] = createSignal(null) - const [hasMore, setHasMore] = createSignal(false) - const [loading, setLoading] = createSignal(false) - const [searched, setSearched] = createSignal(false) - - const run = async (append: boolean) => { - const q = query().trim() - if (!q) return - setLoading(true) - try { - const page = await client.searchMessages({ - q, - limit: 30, - cursor: append ? cursor() ?? undefined : undefined, - }) - const incoming = page.messages ?? [] - setResults((prev) => (append ? [...prev, ...incoming] : incoming)) - setCursor(page.nextCursor) - setHasMore(page.hasMore) - setSearched(true) - } catch (error) { - console.error("Search failed:", error) - const { showToast } = await import("@/utils/toast") - showToast({ - variant: "error", - title: "Search failed", - description: error instanceof Error ? error.message : String(error), - }) - } finally { - setLoading(false) - } - } - - return ( -
-
- setQuery(e.currentTarget.value)} - onKeyDown={(e) => { - e.stopPropagation() - if (e.key === "Enter") void run(false) - }} - /> - -
- - - 0} - fallback={
No matching messages.
} - > -
- - {(msg) => ( - - )} - - -
- -
-
-
-
-
-
- ) -} diff --git a/packages/app/src/components/im/thread-view.tsx b/packages/app/src/components/im/thread-view.tsx deleted file mode 100644 index c4fcd05b..00000000 --- a/packages/app/src/components/im/thread-view.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { createSignal, createEffect, For, Show, type Component } from "solid-js" -import { IconButton } from "@deepagent-code/ui/icon-button" -import { Button } from "@deepagent-code/ui/button" -import { MessageItem } from "./message-item" -import { MessageComposer } from "./message-composer" -import { useIMClient } from "@/utils/im-client" -import type { LocalMessage } from "@/hooks/use-im-websocket" -import type { AgentDescriptor } from "./types" - -// V4.0 §B3 — a message's reply thread. Overlays the group chat: shows the root message + its replies -// (listThread, keyset paginated via nextCursor), and posts new replies with `replyToID = root.id`. -// The thread endpoint is `GET /groups/:groupId/messages/:messageId/thread`. -export const ThreadView: Component<{ - groupID: string - root: LocalMessage - agents: AgentDescriptor[] - onClose: () => void -}> = (props) => { - const client = useIMClient() - const [replies, setReplies] = createSignal([]) - const [cursor, setCursor] = createSignal(null) - const [hasMore, setHasMore] = createSignal(false) - const [loading, setLoading] = createSignal(true) - - const load = (append: boolean) => { - setLoading(true) - client - .listThread(props.groupID, props.root.id, 50, append ? cursor() ?? undefined : undefined) - .then((page) => { - const incoming = page.messages ?? [] - setReplies((prev) => (append ? [...prev, ...incoming] : incoming)) - setCursor(page.nextCursor) - setHasMore(page.hasMore) - setLoading(false) - }) - .catch((error) => { - console.error("Failed to load thread:", error) - setLoading(false) - }) - } - - // Reload whenever the root message changes. - createEffect(() => { - props.root.id - setReplies([]) - setCursor(null) - load(false) - }) - - const sendReply = async (content: string) => { - try { - const created = await client.createMessage(props.groupID, { - content, - type: "text", - replyToID: props.root.id, - }) - setReplies((prev) => [...prev, created as LocalMessage]) - } catch (error) { - console.error("Failed to send reply:", error) - const { showToast } = await import("@/utils/toast") - showToast({ - variant: "error", - title: "Failed to send reply", - description: error instanceof Error ? error.message : String(error), - }) - } - } - - return ( -
-
- Thread - -
- -
- {/* root message */} -
- -
-
- {replies().length} {replies().length === 1 ? "reply" : "replies"} -
- - 0} - fallback={
Loading thread…
} - > - {(reply) => } - -
- -
-
-
-
- - -
- ) -} diff --git a/packages/app/src/components/im/types.ts b/packages/app/src/components/im/types.ts index 17dbb276..9366a73c 100644 --- a/packages/app/src/components/im/types.ts +++ b/packages/app/src/components/im/types.ts @@ -40,19 +40,3 @@ export interface IMMessage { updatedAt: number deletedAt: number | null } - -// V4.0 §B3 file attachments. Mirrors the server's `IMAttachmentResponse` -// (packages/deepagent-code/src/server/routes/instance/httpapi/groups/im.ts). -export interface IMAttachment { - id: string - workspaceID: string - projectID: string | null - groupID: string | null - messageID: string | null - uploadedBy: string - filename: string - mime: string - sizeBytes: number - checksum: string - createdAt: number -} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9aee1c96..580fd9a3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -37,7 +37,6 @@ import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@deepagent-code/ui/button" -import { Spinner } from "@deepagent-code/ui/spinner" import { DockShellForm, DockTray } from "@deepagent-code/ui/dock-surface" import { Icon, type IconProps } from "@deepagent-code/ui/icon" import { ProviderIcon } from "@deepagent-code/ui/provider-icon" @@ -57,8 +56,6 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { PanelButton } from "@/components/deepagent/panel-button" import { fetchCapabilities } from "@/components/deepagent/panel-goal.api" -import { ApprovalControl } from "@/components/deepagent/approval-control" -import { ModeSelector, useModeLabel } from "@/components/deepagent/mode-selector" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" @@ -146,7 +143,6 @@ export const PromptInput: Component = (props) => { const command = useCommand() const permission = usePermission() const language = useLanguage() - const modeLabel = useModeLabel() const platform = usePlatform() const pickDirectory = useDirectoryPicker() const { params, tabs, view } = useSessionLayout() @@ -159,10 +155,6 @@ export const PromptInput: Component = (props) => { let draftPreparePrompt: { prompt: Prompt; cursor: number } | undefined const [draftPreparing, setDraftPreparing] = createSignal(false) - // Live text streamed from intelligence refinement. Shown in a panel BELOW the editor while the raw - // user input stays untouched in the editor above. Preserved on cancel so the user keeps both their - // input and whatever draft was generated so far; cleared only on approved submit or explicit dismiss. - const [draftPreview, setDraftPreview] = createSignal("") const [draftReview, setDraftReview] = createSignal< | { draft: DeepAgentPromptPrepareResult @@ -248,11 +240,10 @@ export const PromptInput: Component = (props) => { draftPreparePrompt = { prompt: current, cursor: prompt.cursor() ?? promptLength(current) } } setDraftPreparing(true) - // Reset any leftover preview so the panel starts clean, then show a generating placeholder until - // the first delta arrives. The raw user input stays in the editor above — we do NOT overwrite it. - setDraftPreview("") setStore("mode", "normal") setStore("popover", null) + const text = language.t("prompt.generating") + prompt.set(makeTextPrompt(text), text.length) requestAnimationFrame(() => { editorRef.blur() queueScroll() @@ -261,41 +252,12 @@ export const PromptInput: Component = (props) => { const stopDraftPrepare = () => { setDraftPreparing(false) - // Keep the raw input reference around while a draft (partial or reviewable) is still on screen so - // the user can restore it; only drop it once nothing references the original prompt anymore. requestAnimationFrame(() => { - if (draftReview() || draftPreview()) return + if (draftReview()) return draftPreparePrompt = undefined }) } - const updateDraftPrepare = (preview: string) => { - if (!draftPreparing() || !preview) return - setDraftPreview(preview) - queueScroll() - } - - // Dismiss a preserved partial draft (from a canceled preparation) without touching the editor input. - const dismissDraftPreview = () => { - setDraftPreview("") - if (!draftReview() && !draftPreparing()) draftPreparePrompt = undefined - } - - // Copy the preserved partial draft into the editor so the user can edit and resubmit it. A canceled - // stream has no server-persisted draft id, so it can't be submitted as an intelligence draft directly. - const useDraftPreview = () => { - const preview = draftPreview().trim() - if (!preview) return - prompt.set(makeTextPrompt(preview), preview.length) - setDraftPreview("") - draftPreparePrompt = undefined - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, preview.length) - queueScroll() - }) - } - const activeFileTab = createSessionTabs({ tabs, pathFromTab: files.pathFromTab, @@ -427,7 +389,7 @@ export const PromptInput: Component = (props) => { .join("") return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 }) - const stopping = createMemo(() => draftPreparing() || (working() && blank())) + const stopping = createMemo(() => working() && blank()) const tip = () => { if (stopping()) { return ( @@ -1281,9 +1243,6 @@ export const PromptInput: Component = (props) => { const editable = draft.preview.trim() || draft.goal.trim() draftReviewResolve = resolve setDraftPreparing(false) - // Preparation succeeded and produced a persisted, submittable draft: transition from the live - // preview panel to the editable review. The editor now holds the draft for the user to approve. - setDraftPreview("") setDraftReview({ draft, originalPrompt: original.prompt, originalCursor: original.cursor }) setStore("mode", "normal") setStore("popover", null) @@ -1318,7 +1277,6 @@ export const PromptInput: Component = (props) => { onAbort: props.onAbort, onSubmit: props.onSubmit, onPromptPrepareStart: startDraftPrepare, - onPromptPrepareProgress: updateDraftPrepare, onPromptPrepareEnd: stopDraftPrepare, confirmPromptDraft, }) @@ -1329,12 +1287,7 @@ export const PromptInput: Component = (props) => { event.preventDefault() return } - if (draftPreparing()) { - event.preventDefault() - void abort() - return - } - if (composing()) { + if (draftPreparing() || composing()) { event.preventDefault() return } @@ -1343,14 +1296,11 @@ export const PromptInput: Component = (props) => { confirmCurrentDraft() return } - // A fresh submission supersedes any paused draft from a prior canceled preparation. - if (draftPreview()) setDraftPreview("") handleSubmit(event) } const handleKeyDown = (event: KeyboardEvent) => { if (draftPreparing()) { - if (event.key === "Escape" || (event.ctrlKey && event.code === "KeyG")) void abort() event.preventDefault() event.stopPropagation() return @@ -1666,53 +1616,6 @@ export const PromptInput: Component = (props) => { onPress: () => void addProject(), })) - // Live/paused draft panel shown BELOW the editor. While preparing it streams the refined prompt; - // after a cancel it stays as a preserved partial draft the user can adopt (→ editor) or dismiss. - const draftPreviewPanel = () => ( - 0}> -
-
- - - - - {draftPreparing() - ? language.t("prompt.draft.preview.streaming") - : language.t("prompt.draft.preview.paused")} - -
-
- {draftPreview().trim() || language.t("prompt.generating")} -
- 0}> -
- - -
-
-
-
- ) - const draftReviewBar = () => ( {(_review) => ( @@ -1855,9 +1758,7 @@ export const PromptInput: Component = (props) => { "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", - // Editor keeps the raw user input during preparation (the streamed draft shows in the - // panel below), so it reads normally — locked to edits, not greyed as a placeholder. - "cursor-wait": draftPreparing(), + "cursor-wait text-text-weak": draftPreparing(), }} style={{ "padding-bottom": space }} /> @@ -1896,11 +1797,11 @@ export const PromptInput: Component = (props) => {
- + = (props) => {
- {draftPreviewPanel()} @@ -1979,28 +1879,23 @@ export const PromptInput: Component = (props) => { title={language.t("command.agent.cycle")} keybind={command.keybind("agent.cycle")} > - { + local.agent.set(value) + restoreFocus() }} - onClose={restoreFocus} - > - {modeLabel(local.agent.current()?.name)} - - + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-agent" }} + variant="ghost" + /> - -
- -
-
diff --git a/packages/app/src/components/prompt-input/scenario-toggle.tsx b/packages/app/src/components/prompt-input/scenario-toggle.tsx index de7a27f4..b7d25e2c 100644 --- a/packages/app/src/components/prompt-input/scenario-toggle.tsx +++ b/packages/app/src/components/prompt-input/scenario-toggle.tsx @@ -17,7 +17,7 @@ const scenarios = [ }, { mode: "intelligence" as const, - icon: "intelligence" as const, + icon: "speech-bubble" as const, label: "prompt.scenario.intelligence" as const, tooltip: "prompt.scenario.intelligence.tooltip" as const, }, diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 1eb5e2da..50935f36 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -29,7 +29,6 @@ const preparedDrafts: Array<{ }> = [] const sentPromptAsync: Array<{ directory: string; metadata?: unknown; text?: string }> = [] const promptPrepareEvents: string[] = [] -const promptPrepareProgress: string[] = [] let params: { id?: string } = {} let selected = "/repo/worktree-a" @@ -73,10 +72,8 @@ const clientFor = (directory: string) => { }, client: { request: async (payload: { - url?: string path?: { sessionID?: string } body?: { mode?: string; output_language?: string; parts?: Array<{ type: string; text?: string }> } - signal?: AbortSignal }) => { const text = payload.body?.parts?.find((part) => part.type === "text")?.text preparedDrafts.push({ @@ -94,43 +91,16 @@ const clientFor = (directory: string) => { }, }) } - if (text === "prepare waits") { - return { - data: new ReadableStream({ - start(controller) { - payload.signal?.addEventListener( - "abort", - () => controller.error(new DOMException("Aborted", "AbortError")), - { once: true }, - ) - }, - }), - } - } - const result = { - prompt_draft_id: "prompt_draft:test:1", - context_plan_id: "context_plan:test:1", - state: "draft_ready", - mode: payload.body?.mode ?? "intelligence", - route: text === "hello" ? "general" : "code", - goal: "Prepared goal", - preview: "# Prepared prompt", - } return { - data: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - [ - `data: ${JSON.stringify({ type: "progress", preview: "Prepared" })}\n\n`, - `data: ${JSON.stringify({ type: "progress", preview: "Prepared goal" })}\n\n`, - `data: ${JSON.stringify({ type: "result", result })}\n\n`, - ].join(""), - ), - ) - controller.close() - }, - }), + data: { + prompt_draft_id: "prompt_draft:test:1", + context_plan_id: "context_plan:test:1", + state: "draft_ready", + mode: payload.body?.mode ?? "intelligence", + route: text === "hello" ? "general" : "code", + goal: "Prepared goal", + preview: "# Prepared prompt", + }, } }, }, @@ -313,7 +283,6 @@ beforeEach(() => { preparedDrafts.length = 0 sentPromptAsync.length = 0 promptPrepareEvents.length = 0 - promptPrepareProgress.length = 0 promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 } selected = "/repo/worktree-a" variant = undefined @@ -470,7 +439,6 @@ describe("prompt submit worktree selection", () => { setMode: () => undefined, setPopover: () => undefined, onPromptPrepareStart: () => promptPrepareEvents.push("start"), - onPromptPrepareProgress: (preview) => promptPrepareProgress.push(preview), onPromptPrepareEnd: () => promptPrepareEvents.push("end"), confirmPromptDraft: async () => ({ editedGoal: "Edited prepared goal" }), onSubmit: () => undefined, @@ -482,7 +450,6 @@ describe("prompt submit worktree selection", () => { await flushAsyncSubmit() expect(promptPrepareEvents).toEqual(["start", "end"]) - expect(promptPrepareProgress).toEqual(["Prepared", "Prepared goal"]) expect(preparedDrafts).toEqual([ { directory: "/repo/main", sessionID: "session-1", mode: "intelligence", outputLanguage: "english", text: "ls" }, ]) @@ -565,13 +532,7 @@ describe("prompt submit worktree selection", () => { expect(confirms).toEqual([]) expect(preparedDrafts).toEqual([ - { - directory: "/repo/main", - sessionID: "session-1", - mode: "intelligence", - outputLanguage: "english", - text: "hello", - }, + { directory: "/repo/main", sessionID: "session-1", mode: "intelligence", outputLanguage: "english", text: "hello" }, ]) expect(sentPromptAsync[0]?.text).toBe("hello") expect(sentPromptAsync[0]?.metadata).toEqual({ @@ -624,38 +585,4 @@ describe("prompt submit worktree selection", () => { expect(sentPromptAsync).toEqual([]) promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 } }) - - test("stops intelligence prompt preparation without submitting", async () => { - params = { id: "session-1" } - promptMode = "intelligence" - promptValue[0] = { type: "text", content: "prepare waits", start: 0, end: 13 } - - const submit = createPromptSubmit({ - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - onPromptPrepareStart: () => promptPrepareEvents.push("start"), - onPromptPrepareEnd: () => promptPrepareEvents.push("end"), - onSubmit: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - await flushAsyncSubmit() - await submit.abort() - await flushAsyncSubmit() - - expect(promptPrepareEvents).toEqual(["start", "end"]) - expect(sentPromptAsync).toEqual([]) - promptValue[0] = { type: "text", content: "ls", start: 0, end: 2 } - }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 7e98fdf9..baa67555 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -77,17 +77,10 @@ type RawSdkClient = { path?: Record body?: unknown headers?: Record - parseAs?: "stream" - signal?: AbortSignal }): Promise<{ data?: TData }> } } -type DeepAgentPromptPrepareStreamEvent = - | { type: "progress"; preview: string } - | { type: "result"; result: DeepAgentPromptPrepareResult } - | { type: "error"; message: string } - type FollowupSendInput = { client: ReturnType["client"] serverSync: ReturnType @@ -98,9 +91,7 @@ type FollowupSendInput = { before?: () => Promise | boolean onBeforeSubmit?: () => void onPromptPrepareStart?: () => void - onPromptPrepareProgress?: (preview: string) => void onPromptPrepareEnd?: () => void - promptPrepareSignal?: AbortSignal promptOutputLanguage?: DeepAgentPromptOutputLanguage confirmPromptDraft?: (draft: DeepAgentPromptPrepareResult) => Promise } @@ -120,13 +111,11 @@ async function prepareDeepAgentPromptDraft(input: { mode: DeepAgentPromptModeForConfirmation outputLanguage: DeepAgentPromptOutputLanguage parts: SessionPromptAsyncInput["parts"] - signal?: AbortSignal - onProgress?: (preview: string) => void }) { const raw = input.client as unknown as RawSdkClient - const response = await raw.client.request>({ + const response = await raw.client.request({ method: "POST", - url: "/session/{sessionID}/prompt_prepare_stream", + url: "/session/{sessionID}/prompt_prepare", path: { sessionID: input.sessionID }, body: { mode: input.mode, @@ -136,43 +125,9 @@ async function prepareDeepAgentPromptDraft(input: { headers: { "Content-Type": "application/json", }, - parseAs: "stream", - signal: input.signal, }) - if (!response.data) throw new Error("Prompt draft prepare returned no stream") - - const decoder = new TextDecoder() - const reader = response.data.getReader() - let buffer = "" - let prepared: DeepAgentPromptPrepareResult | undefined - const readEvent = (block: string) => { - const data = block - .split("\n") - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trimStart()) - .join("\n") - if (!data) return - const event = JSON.parse(data) as DeepAgentPromptPrepareStreamEvent - if (event.type === "progress") { - input.onProgress?.(event.preview) - return - } - if (event.type === "error") throw new Error(event.message) - prepared = event.result - } - - while (true) { - const part = await reader.read() - if (part.done) break - buffer += decoder.decode(part.value, { stream: true }) - const blocks = buffer.split("\n\n") - buffer = blocks.pop() ?? "" - blocks.forEach(readEvent) - } - buffer += decoder.decode() - if (buffer.trim()) readEvent(buffer) - if (!prepared) throw new Error("Prompt draft prepare returned no result") - return prepared + if (!response.data) throw new Error("Prompt draft prepare returned no data") + return response.data } export type DeepAgentPromptSuggestion = { @@ -285,13 +240,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { mode, outputLanguage: input.promptOutputLanguage ?? "english", parts: preparedParts.requestParts, - signal: input.promptPrepareSignal, - onProgress: input.onPromptPrepareProgress, }) } catch (err) { setIdle() input.onPromptPrepareEnd?.() - if (input.promptPrepareSignal?.aborted) return false throw err } input.onPromptPrepareEnd?.() @@ -402,7 +354,6 @@ type PromptSubmitInput = { onAbort?: () => void onSubmit?: () => void onPromptPrepareStart?: () => void - onPromptPrepareProgress?: (preview: string) => void onPromptPrepareEnd?: () => void confirmPromptDraft?: (draft: DeepAgentPromptPrepareResult) => Promise } @@ -430,7 +381,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { const language = useLanguage() const params = useParams() const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID) - let activePreparation: { sessionID: string; controller: AbortController } | undefined const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -442,7 +392,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const abort = async () => { - const sessionID = activePreparation?.sessionID ?? params.id + const sessionID = params.id if (!sessionID) return Promise.resolve() // D3: any stop resets the scenario to `direct` and pauses scenario automation for this @@ -452,12 +402,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.onAbort?.() - if (activePreparation) { - activePreparation.controller.abort() - activePreparation = undefined - return Promise.resolve() - } - const key = pendingKey(sessionID) const queued = pending.get(key) if (queued) { @@ -734,7 +678,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const messageID = Identifier.ascending("message") const preparesPromptDraft = promptPipelineMode(draft.metadata) === "intelligence" - const preparationAbort = preparesPromptDraft ? new AbortController() : undefined const removeOptimisticMessage = () => { sync.session.optimistic.remove({ @@ -757,7 +700,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { sync.set("session_status", session.id, { type: "busy" }) } - const controller = preparationAbort ?? new AbortController() + const controller = new AbortController() const cleanup = () => { if (sessionDirectory === projectDirectory) { sync.set("session_status", session.id, { type: "idle" }) @@ -806,12 +749,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { return true } - if (preparationAbort) activePreparation = { sessionID: session.id, controller: preparationAbort } - const clearActivePreparation = () => { - if (activePreparation?.controller !== preparationAbort) return - activePreparation = undefined - } - void sendFollowupDraft({ client, sync, @@ -822,14 +759,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { before: waitForWorktree, onBeforeSubmit: input.onSubmit, onPromptPrepareStart: input.onPromptPrepareStart, - onPromptPrepareProgress: input.onPromptPrepareProgress, onPromptPrepareEnd: input.onPromptPrepareEnd, - promptPrepareSignal: preparationAbort?.signal, promptOutputLanguage: promptOutputLanguage(language.locale()), confirmPromptDraft: input.confirmPromptDraft, }) .then((sent) => { - clearActivePreparation() pending.delete(pendingKey(session.id)) if (sent) { if (preparesPromptDraft) { @@ -846,7 +780,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { restoreInput() }) .catch((err) => { - clearActivePreparation() pending.delete(pendingKey(session.id)) if (sessionDirectory === projectDirectory) { sync.set("session_status", session.id, { type: "idle" }) diff --git a/packages/app/src/components/review/dialog-review.api.ts b/packages/app/src/components/review/dialog-review.api.ts index 3f293b78..3dc44596 100644 --- a/packages/app/src/components/review/dialog-review.api.ts +++ b/packages/app/src/components/review/dialog-review.api.ts @@ -10,9 +10,6 @@ export type KnowledgeItem = { evidence_strength: "strong" | "medium" | "weak" | "none" evidence_refs: string[] approval_status: "pending" | "approved" | "rejected" - // Storage scope, for grouping by project vs global: "durable" (global) or - // "durable:project:". Absent from older servers → treated as global. - scope?: string } type RawSdkClient = { diff --git a/packages/app/src/components/review/dialog-review.tsx b/packages/app/src/components/review/dialog-review.tsx index c3c8a165..ec85e370 100644 --- a/packages/app/src/components/review/dialog-review.tsx +++ b/packages/app/src/components/review/dialog-review.tsx @@ -150,30 +150,29 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { const pending = createMemo(() => (items() ?? []).filter((i) => i.approval_status === "pending" && matchesQuery(i))) const approved = createMemo(() => (items() ?? []).filter((i) => i.approval_status === "approved" && matchesQuery(i))) - // Group by SCOPE, not by type: the governance view shows the user's learned facts split into - // "this project" vs "global". A doc scoped `durable:project:` is project-local; anything else - // (`durable`, or legacy untagged) is global. (Skills + domain-pack seed docs are already excluded - // server-side, so every item here is a human-readable, user-learned fact.) - const scopeOf = (item: KnowledgeItem): "project" | "global" => - item.scope?.startsWith("durable:project:") ? "project" : "global" - const scopeLabel = (scope: string) => - scope === "project" ? language.t("review.scope.project") : language.t("review.scope.global") + // Stable display order for the type boxes. Any unknown type falls back into "other". + const TYPE_ORDER = ["memory", "failure_dossier", "knowledge", "strategy", "methodology", "skill"] as const + const typeLabel = (type: string) => + (TYPE_ORDER as readonly string[]).includes(type) + ? language.t(`review.type.${type}`) + : language.t("review.type.other") - // Project bucket first (most specific to what the user is working on), then global. - const SCOPE_ORDER = ["project", "global"] as const + // Group pending items by type, preserving TYPE_ORDER; trailing "other" bucket for unknown types. const pendingGroups = createMemo(() => { - const byScope = new Map() + const byType = new Map() for (const item of pending()) { - const key = scopeOf(item) - const list = byScope.get(key) + const key = (TYPE_ORDER as readonly string[]).includes(item.type) ? item.type : "other" + const list = byType.get(key) if (list) list.push(item) - else byScope.set(key, [item]) + else byType.set(key, [item]) } - const ordered: Array<{ scope: string; items: KnowledgeItem[] }> = [] - for (const scope of SCOPE_ORDER) { - const list = byScope.get(scope) - if (list?.length) ordered.push({ scope, items: list }) + const ordered: Array<{ type: string; items: KnowledgeItem[] }> = [] + for (const type of TYPE_ORDER) { + const list = byType.get(type) + if (list?.length) ordered.push({ type, items: list }) } + const other = byType.get("other") + if (other?.length) ordered.push({ type: "other", items: other }) return ordered }) @@ -527,7 +526,7 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { } > - {(group) => GroupBox({ boxKey: group.scope, label: scopeLabel(group.scope), items: group.items })} + {(group) => GroupBox({ boxKey: group.type, label: typeLabel(group.type), items: group.items })} 0}> {GroupBox({ boxKey: "approved", label: language.t("review.status.approved"), items: approved() })} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 2e9b6754..6a66fd22 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,25 +1,27 @@ import { AppIcon } from "@deepagent-code/ui/app-icon" import { Button } from "@deepagent-code/ui/button" import { DropdownMenu } from "@deepagent-code/ui/dropdown-menu" +import { Popover } from "@deepagent-code/ui/popover" import { Icon } from "@deepagent-code/ui/icon" import { IconButton } from "@deepagent-code/ui/icon-button" import { Keybind } from "@deepagent-code/ui/keybind" import { Spinner } from "@deepagent-code/ui/spinner" import { showToast } from "@/utils/toast" -import { TooltipKeybind } from "@deepagent-code/ui/tooltip" +import { Tooltip, TooltipKeybind } from "@deepagent-code/ui/tooltip" import { getFilename } from "@deepagent-code/core/util/path" import { createEffect, createMemo, createSignal, For, onMount, Show, type ComponentProps } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" +import { DOCK_PANEL_IDS, useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { focusTerminalById } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" +import { PANEL_VIEW_META } from "@/pages/session/panel-view-registry" import { StatusPopover } from "@/components/status-popover" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" @@ -206,14 +208,33 @@ export function SessionHeader() { ] }) - const toggleTerminal = () => { - const next = !view().terminal.opened() - view().terminal.toggle() - if (!next) return + const terminalOpen = createMemo(() => { + const panel = view().panel + return panel.location("terminal") === "bottom" + ? panel.bottom.opened() && panel.bottom.activeView() === "terminal" + : view().rightPanel.mode() === "terminal" + }) + const toggleTerminal = () => { + view().panel.toggle("terminal") + const panel = view().panel + if (panel.location("terminal") === "side" && view().rightPanel.mode() !== "terminal") return const id = terminal.active() - if (!id) return - focusTerminalById(id) + if (id) focusTerminalById(id) + } + + const bottomPanelOpen = createMemo(() => view().panel.bottom.opened()) + const bottomPanelAvailable = createMemo(() => view().panel.viewsAt("bottom").length > 0) + const toggleBottomPanel = () => view().panel.bottom.toggle() + const [panelViewsOpen, setPanelViewsOpen] = createSignal(false) + const panelViewIDs = createMemo(() => [...DOCK_PANEL_IDS]) + const revealPanelView = (id: (typeof DOCK_PANEL_IDS)[number]) => { + view().panel.reveal(id) + setPanelViewsOpen(false) + } + const movePanelView = (id: (typeof DOCK_PANEL_IDS)[number], target: "bottom" | "side") => { + view().panel.move(id, target) + setPanelViewsOpen(false) } const rightPanelOpen = createMemo(() => view().rightPanel.opened()) @@ -458,13 +479,82 @@ export function SessionHeader() { class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0" onClick={toggleTerminal} aria-label={language.t("command.terminal.toggle")} - aria-expanded={view().terminal.opened()} - aria-controls="terminal-panel" + aria-expanded={terminalOpen()} + aria-controls={view().panel.location("terminal") === "bottom" ? "bottom-panel" : "review-panel"} > - + + + + + + + + + } + > +
+
{language.t("session.panel.views")}
+ + {(id) => ( +
+ + + movePanelView(id, "bottom")} + /> + + + movePanelView(id, "side")} + /> + +
+ )} +
+
+
) diff --git a/packages/app/src/components/settings-v2/import-history.tsx b/packages/app/src/components/settings-v2/import-history.tsx index a351bfe7..918c30d8 100644 --- a/packages/app/src/components/settings-v2/import-history.tsx +++ b/packages/app/src/components/settings-v2/import-history.tsx @@ -228,7 +228,7 @@ export const ImportSection: Component = () => { value={customPath()} onInput={(e) => setCustomPath(e.currentTarget.value)} disabled={running()} - placeholder="~/.codex_backup" + placeholder="/Users/you/.codex_backup" spellcheck={false} autocorrect="off" autocomplete="off" @@ -296,7 +296,7 @@ export const ImportSection: Component = () => { value={cwdFilter()} onInput={(e) => setCwdFilter(e.currentTarget.value)} disabled={running()} - placeholder="~/projects/..." + placeholder="/Users/you/projects/..." spellcheck={false} autocorrect="off" autocomplete="off" @@ -305,22 +305,18 @@ export const ImportSection: Component = () => { -
- - {running() ? t("settings.import.running", "Importing…") : t("settings.import.run.btn", "Import")} - - - - {t("settings.import.cancel", "Cancel")} + +
+ + {running() ? t("settings.import.running", "Importing…") : t("settings.import.run.btn", "Import")} - -
+ + + {t("settings.import.cancel", "Cancel")} + + +
+ diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 000f8a7d..85b596e5 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -22,7 +22,14 @@ const PROVIDER_NOTES = [ { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, { match: (id: string) => id === "deepseek", key: "dialog.provider.deepseek.note" }, + { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, + { match: (id: string) => id === "xai", key: "dialog.provider.xai.note" }, { match: (id: string) => id === "zhipuai", key: "dialog.provider.zhipuai.note" }, + { match: (id: string) => id === "zhipuai-coding-plan", key: "dialog.provider.zhipuai-coding-plan.note" }, + { match: (id: string) => id === "zai", key: "dialog.provider.zai.note" }, + { match: (id: string) => id === "zai-coding-plan", key: "dialog.provider.zai-coding-plan.note" }, + { match: (id: string) => id === "kimi-for-coding", key: "dialog.provider.kimi-for-coding.note" }, + { match: (id: string) => id === "moonshotai-cn", key: "dialog.provider.moonshotai-cn.note" }, ] as const const PROVIDER_ICON_SIZE = 16 diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx index d477bca4..09125316 100644 --- a/packages/app/src/components/settings-v2/servers.tsx +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -2,7 +2,6 @@ import { ButtonV2 } from "@deepagent-code/ui/v2/button-v2" import { Tag } from "@deepagent-code/ui/v2/badge-v2" import { Icon as IconV2 } from "@deepagent-code/ui/v2/icon" import { IconButtonV2 } from "@deepagent-code/ui/v2/icon-button-v2" -import { MenuV2 } from "@deepagent-code/ui/v2/menu-v2" import { TextInputV2 } from "@deepagent-code/ui/v2/text-input-v2" import { useDialog } from "@deepagent-code/ui/context/dialog" import fuzzysort from "fuzzysort" @@ -61,23 +60,12 @@ export const SettingsServersV2: Component = () => { >

{language.t("status.popover.tab.servers")}

- {/* Single "Add server" entry: a menu picks either a direct HTTP server or a Server Edition - gateway connection — two distinct flows behind one button (no more duplicate buttons). */} - - - {language.t("dialog.server.add.button")} - - - - - {language.t("dialog.server.add.menu.http")} - - {language.t("dialog.server.connect.button")} - - - - - + + {language.t("dialog.server.add.button")} + + + {language.t("dialog.server.connect.button")} +
diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 445265d0..ca6d0919 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -730,19 +730,6 @@ color: var(--v2-text-text-faint); } -/* Import: the run action is a full-width bar button under the options list, not a small - right-aligned row control. Cancel (only while running) sits beside it, sized to content. */ -.settings-v2-import-actions { - display: flex; - gap: 8px; - margin-top: 4px; -} - -.settings-v2-import-run { - flex: 1; - justify-content: center; -} - .settings-v2-import-log { margin: 0; padding: 10px 12px; diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 6ec5f86a..4f436f86 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -1,11 +1,12 @@ import { Button } from "@deepagent-code/ui/button" import { useDialog } from "@deepagent-code/ui/context/dialog" import { Icon } from "@deepagent-code/ui/icon" +import { Switch } from "@deepagent-code/ui/switch" import { Tabs } from "@deepagent-code/ui/tabs" +import { showToast } from "@/utils/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { type Accessor, createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" -import { McpManagement } from "@/components/mcp-management" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -13,6 +14,9 @@ import { ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { type ServerHealth } from "@/utils/server-health" import { useGlobal } from "@/context/global" +import { useMcpRemove, useMcpToggle } from "@/context/mcp" +import { DialogAddMcp } from "@/components/dialog-add-mcp" +import { DialogConfigureMcp } from "@/components/dialog-configure-mcp" const listServersByHealth = ( list: ServerConnection.Any[], @@ -242,6 +246,14 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const language = useLanguage() const navigate = useNavigate() + const fail = (err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } + createEffect(() => { if (!props.shown()) return }) @@ -253,11 +265,30 @@ export function StatusPopoverBody(props: { shown: Accessor }) { dialogRun += 1 }) const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health)) + const toggleMcp = useMcpToggle() + const removeMcp = useMcpRemove() const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) + const [selectedMcp, setSelectedMcp] = createSignal() const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) + createEffect(() => { + const names = mcpNames() + if (names.length === 0) { + setSelectedMcp(undefined) + return + } + if (!selectedMcp() || !names.includes(selectedMcp()!)) setSelectedMcp(names[0]) + }) + + const deleteSelectedMcp = () => { + const name = selectedMcp() + if (!name || removeMcp.isPending) return + if (!window.confirm(language.t("dialog.mcp.delete.confirm", { name }))) return + removeMcp.mutate(name) + } + return (
}) {
- +
+ + + +
+
+ 0} + fallback={ +
{language.t("dialog.mcp.empty")}
+ } + > + + {(name) => { + const status = () => mcpStatus(name) + const enabled = () => status() === "connected" + return ( + + ) + }} + +
+
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 41663f9d..f0e6f136 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -6,16 +6,28 @@ import { Popover } from "@deepagent-code/ui/popover" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { useLanguage } from "@/context/language" import { useServer } from "@/context/server" +import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" +const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) export function StatusPopover() { const language = useLanguage() const server = useServer() const global = useGlobal() + const sync = useSync() const [shown, setShown] = createSignal(false) - const serverHealth = () => global.servers.health[server.key]?.healthy + const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready) + const mcpIssue = createMemo(() => { + const mcp = Object.values(sync.data.mcp ?? {}) + const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") + const warn = mcp.some((item) => item.status === "needs_auth") + if (failed) return "critical" as const + if (warn) return "warning" as const + }) + const serverHealthy = () => global.servers.health[server.key]?.healthy === true + const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue()) return (
- +
@@ -54,15 +67,50 @@ export function StatusPopover() {
} > - + ) } -export function StatusPopoverV2(_props: { scope?: "server" }) { - return +export function StatusPopoverV2(props: { scope?: "server" }) { + if (props.scope === "server") return + return +} + +function DirectoryStatusPopover() { + const language = useLanguage() + const server = useServer() + const global = useGlobal() + const sync = useSync() + const [shown, setShown] = createSignal(false) + const serverHealth = () => global.servers.health[server.key]?.healthy + const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready) + const mcpIssue = createMemo(() => { + const mcp = Object.values(sync.data.mcp ?? {}) + const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") + const warn = mcp.some((item) => item.status === "needs_auth") + if (failed) return "critical" as const + if (warn) return "warning" as const + }) + const healthy = createMemo(() => serverHealth() === true && !mcpIssue()) + const state = createMemo(() => ({ + shown: shown(), + ready: ready(), + healthy: healthy(), + serverHealth: serverHealth(), + issue: mcpIssue(), + label: language.t("status.popover.trigger"), + onOpenChange: setShown, + body: () => ( + + + + ), + })) + + return } function ServerStatusPopover() { @@ -76,7 +124,7 @@ function ServerStatusPopover() { ready: serverHealth() !== undefined, healthy: serverHealth() === true, serverHealth: serverHealth(), - label: language.t("status.popover.tab.servers"), + label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -93,6 +141,7 @@ type StatusPopoverState = { ready: boolean healthy: boolean serverHealth: boolean | undefined + issue?: "critical" | "warning" label: string onOpenChange: (value: boolean) => void body: () => JSX.Element @@ -114,7 +163,10 @@ function StatusPopoverView(props: { state: StatusPopoverState }) { const statusDotClass = () => ({ "absolute rounded-full": true, "bg-icon-success-base": props.state.ready && props.state.healthy, - "bg-icon-critical-base": props.state.serverHealth === false, + "bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning", + "bg-icon-critical-base": + props.state.serverHealth === false || + (props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"), "bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready, }) @@ -140,7 +192,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) { }} trigger={
- +
= { export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY autoFocus?: boolean + runtimeId?: string onSubmit?: () => void - onCleanup?: (pty: Partial & { id: string }) => void - onConnect?: () => void - onConnectError?: (error: unknown) => void + onStatusChange?: (status: TerminalStatus, error?: TerminalFailure) => void } let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty }> | undefined @@ -139,33 +136,6 @@ const useTerminalUiBindings = (input: { input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) } -const persistTerminal = (input: { - term: Term | undefined - addon: SerializeAddon | undefined - cursor: number - id: string - onCleanup?: (pty: Partial & { id: string }) => void -}) => { - if (!input.addon || !input.onCleanup || !input.term) return - const buffer = (() => { - try { - return input.addon.serialize() - } catch { - debugTerminal("failed to serialize terminal buffer") - return "" - } - })() - - input.onCleanup({ - id: input.id, - buffer, - cursor: input.cursor, - rows: input.term.rows, - cols: input.term.cols, - scrollY: input.term.getViewportY(), - }) -} - export const Terminal = (props: TerminalProps) => { const platform = usePlatform() const sdk = useSDK() @@ -181,41 +151,64 @@ export const Terminal = (props: TerminalProps) => { const password = auth?.password ?? "" const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) - const id = local.pty.id - const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" - const restoreSize = - restore && - typeof local.pty.cols === "number" && - Number.isSafeInteger(local.pty.cols) && - local.pty.cols > 0 && - typeof local.pty.rows === "number" && - Number.isSafeInteger(local.pty.rows) && - local.pty.rows > 0 - ? { cols: local.pty.cols, rows: local.pty.rows } - : undefined - const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined + const [local, others] = splitProps(props, [ + "pty", + "class", + "classList", + "autoFocus", + "runtimeId", + "onSubmit", + "onStatusChange", + ]) + const id = local.pty.ptyId let ws: WebSocket | undefined let term: Term | undefined let _ghostty: Ghostty - let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void let fitFrame: number | undefined let sizeTimer: ReturnType | undefined let pendingSize: { cols: number; rows: number } | undefined let lastSize: { cols: number; rows: number } | undefined + let connected = false + let hasOutput = false + let reportedReady = false let disposed = false const cleanups: VoidFunction[] = [] - const start = - typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined - let cursor = start ?? 0 - let seek = start !== undefined ? start : restore ? -1 : 0 + let cursor = 0 + let seek = 0 let output: ReturnType | undefined let drop: VoidFunction | undefined let reconn: ReturnType | undefined let tries = 0 + const isFailure = (error: unknown): error is TerminalFailure => + Boolean( + error && + typeof error === "object" && + "operation" in error && + "code" in error && + typeof error.code === "string" && + "message" in error && + typeof error.message === "string", + ) + + const failure = (error: unknown, status?: number) => { + if (isFailure(error)) return error + return terminalFailure({ operation: "connect", error, status, ptyId: id, directory, runtimeId: local.runtimeId }) + } + + const setStatus = (status: TerminalStatus, error?: TerminalFailure) => { + if (disposed) return + local.onStatusChange?.(status, error) + } + + const markReady = () => { + if (reportedReady) return + reportedReady = true + setStatus("ready") + } + const cleanup = () => { if (!cleanups.length) return const fns = cleanups.splice(0).reverse() @@ -228,15 +221,24 @@ export const Terminal = (props: TerminalProps) => { } } - const pushSize = (cols: number, rows: number) => { - return client.pty - .update({ - ptyID: id, - size: { cols, rows }, - }) - .catch((err) => { - debugTerminal("failed to sync terminal size", err) - }) + const pushSize = async (cols: number, rows: number) => { + if (!connected || disposed) return + const result = await client.pty.update({ ptyID: id, size: { cols, rows } }, { throwOnError: false }) + if (result.response?.ok) return + const error = terminalFailure({ + operation: "resize", + error: result.error, + status: result.response?.status, + ptyId: id, + directory, + runtimeId: local.runtimeId, + }) + if (error.status === 404) { + connected = false + setStatus("exited", error) + return + } + console.warn("[terminal] resize failed", error) } const getTerminalColors = (): TerminalColors => { @@ -279,9 +281,11 @@ export const Terminal = (props: TerminalProps) => { if (lastSize?.cols === cols && lastSize?.rows === rows) return pendingSize = { cols, rows } + if (!connected) return if (!lastSize) { lastSize = pendingSize + pendingSize = undefined void pushSize(cols, rows) return } @@ -354,6 +358,7 @@ export const Terminal = (props: TerminalProps) => { onMount(() => { const run = async () => { + setStatus("connecting") const loaded = await loadGhostty() if (disposed) return @@ -363,8 +368,6 @@ export const Terminal = (props: TerminalProps) => { const t = new mod.Terminal({ cursorBlink: true, cursorStyle: "bar", - cols: restoreSize?.cols, - rows: restoreSize?.rows, fontSize: 14, fontFamily: terminalFontFamily(settings.appearance.terminalFont()), allowTransparency: false, @@ -408,12 +411,9 @@ export const Terminal = (props: TerminalProps) => { }) const fit = new mod.FitAddon() - const serializer = new SerializeAddon() cleanups.push(() => disposeIfDisposable(fit)) - t.loadAddon(serializer) t.loadAddon(fit) fitAddon = fit - serializeAddon = serializer t.open(container) useTerminalUiBindings({ @@ -440,7 +440,7 @@ export const Terminal = (props: TerminalProps) => { cleanups.push(() => disposeIfDisposable(onData)) const onKey = t.onKey((key) => { if (key.key == "Enter") { - props.onSubmit?.() + local.onSubmit?.() } }) cleanups.push(() => disposeIfDisposable(onKey)) @@ -452,31 +452,9 @@ export const Terminal = (props: TerminalProps) => { cleanups.push(() => window.removeEventListener("resize", handleResize)) } - const write = (data: string) => - new Promise((resolve) => { - if (!output) { - resolve() - return - } - output.push(data) - output.flush(resolve) - }) - - if (restore && restoreSize) { - await write(restore) - fit.fit() - scheduleSize(t.cols, t.rows) - if (scrollY !== undefined) t.scrollToLine(scrollY) - startResize() - } else { - fit.fit() - scheduleSize(t.cols, t.rows) - if (restore) { - await write(restore) - if (scrollY !== undefined) t.scrollToLine(scrollY) - } - startResize() - } + fit.fit() + scheduleSize(t.cols, t.rows) + startResize() const once = { value: false } const decoder = new TextDecoder() @@ -485,7 +463,9 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return if (once.value) return once.value = true - local.onConnectError?.(err) + connected = false + const error = failure(err) + setStatus(error.status === 404 ? "exited" : "error", error) } const gone = () => @@ -510,34 +490,40 @@ export const Terminal = (props: TerminalProps) => { if (err instanceof Error && err.message.includes("Request is not supported")) return throw err }) - if (!result) return + if (!result) return {} // With throwOnError:false a completed request always carries a response; the SDK types it // optional (it can be absent when the request itself failed to build), so guard once. const response = result.response if (!response) throw new Error("PTY connect ticket failed: no response from server") - if (response.status === 200 && result.data?.ticket) return result.data.ticket - if (response.status === 404 || response.status === 405) return + if (response.status === 200 && result.data?.ticket) return { ticket: result.data.ticket } + if (response.status === 405) return {} + if (response.status === 404) throw failure(result.error, response.status) if (response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${response.status}`) + throw failure(new Error("PTY connect ticket rejected by origin or CSRF checks"), response.status) + throw failure(result.error ?? new Error(`PTY connect ticket failed with ${response.status}`), response.status) } const retry = (err: unknown) => { if (disposed) return if (reconn !== undefined) return + if (tries >= 5) { + fail(err) + return + } const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000) + setStatus("reconnecting", failure(err)) reconn = setTimeout(async () => { reconn = undefined if (disposed) return if (await gone()) { if (disposed) return - fail(err) + fail(failure(new Error("Terminal process no longer exists"), 404)) return } if (disposed) return tries += 1 - open() + void open() }, ms) } @@ -545,12 +531,13 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return drop?.() - const ticket = await connectToken().catch((err) => { + const connection = await connectToken().catch((err) => { fail(err) return undefined }) if (once.value) return if (disposed) return + if (!connection) return const socket = new WebSocket( terminalWebSocketURL({ @@ -558,7 +545,7 @@ export const Terminal = (props: TerminalProps) => { id, directory, cursor: seek, - ticket, + ticket: connection.ticket, sameOrigin, username, password, @@ -571,8 +558,11 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { if (disposed) return tries = 0 - local.onConnect?.() + connected = true + reportedReady = false + lastSize = undefined scheduleSize(t.cols, t.rows) + markReady() } const handleMessage = (event: MessageEvent) => { @@ -582,11 +572,12 @@ export const Terminal = (props: TerminalProps) => { if (bytes[0] !== 0) return const json = decoder.decode(bytes.subarray(1)) try { - const meta = JSON.parse(json) as { cursor?: unknown } - const next = meta?.cursor + const meta: unknown = JSON.parse(json) + const next = meta && typeof meta === "object" && "cursor" in meta ? meta.cursor : undefined if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { cursor = next seek = next + if (hasOutput || next > 0) markReady() } } catch (err) { debugTerminal("invalid websocket control frame", err) @@ -596,6 +587,8 @@ export const Terminal = (props: TerminalProps) => { const data = typeof event.data === "string" ? event.data : "" if (!data) return + hasOutput = true + markReady() output?.push(data) cursor += data.length seek = cursor @@ -617,6 +610,8 @@ export const Terminal = (props: TerminalProps) => { } const handleClose = (event: CloseEvent) => { + connected = false + reportedReady = false if (ws === socket) ws = undefined if (drop === stop) drop = undefined socket.removeEventListener("open", handleOpen) @@ -624,7 +619,6 @@ export const Terminal = (props: TerminalProps) => { socket.removeEventListener("error", handleError) socket.removeEventListener("close", handleClose) if (disposed) return - if (event.code === 1000) return retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) } @@ -635,17 +629,14 @@ export const Terminal = (props: TerminalProps) => { socket.addEventListener("close", handleClose) } - open() + void open() } void run().catch((err) => { if (disposed) return - showToast({ - variant: "error", - title: language.t("terminal.connectionLost.title"), - description: err instanceof Error ? err.message : language.t("terminal.connectionLost.description"), - }) - local.onConnectError?.(err) + const error = failure(err) + console.error("[terminal] renderer failed", error) + setStatus(error.status === 404 ? "exited" : "error", error) }) }) @@ -654,13 +645,11 @@ export const Terminal = (props: TerminalProps) => { if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) if (reconn !== undefined) clearTimeout(reconn) + connected = false drop?.() if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) - const finalize = () => { - persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) - cleanup() - } + const finalize = () => cleanup() if (!output) { finalize() @@ -674,6 +663,7 @@ export const Terminal = (props: TerminalProps) => {
{ expect(calls).toEqual(["C:\\tmp\\demo"]) queue.dispose() }) - - test("does not enqueue rejected directories", async () => { - const calls: string[] = [] - const queue = createRefreshQueue({ - paused: () => false, - accept: (directory) => directory !== "/", - bootstrap: async () => {}, - bootstrapInstance: (directory) => { - calls.push(directory) - }, - }) - - queue.push("/") - queue.push("/project") - - await tick() - - expect(calls).toEqual(["/project"]) - queue.dispose() - }) }) diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index b5653717..947e31ac 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -3,7 +3,6 @@ type QueueInput = { bootstrap: () => Promise bootstrapInstance: (directory: string) => Promise | void key?: (directory: string) => string - accept?: (directory: string) => boolean } export function createRefreshQueue(input: QueueInput) { @@ -37,7 +36,6 @@ export function createRefreshQueue(input: QueueInput) { const push = (directory: string) => { if (!directory) return - if (input.accept && !input.accept(directory)) return queued.set(key(directory), directory) if (input.paused()) return schedule() diff --git a/packages/app/src/context/layout-helpers.ts b/packages/app/src/context/layout-helpers.ts index dc9438bf..cf29313a 100644 --- a/packages/app/src/context/layout-helpers.ts +++ b/packages/app/src/context/layout-helpers.ts @@ -1,5 +1,126 @@ import type { Accessor } from "solid-js" +export type PanelView = "terminal" | "debug-console" | "problems" +export type PanelLocation = "bottom" | "side" + +export const PANEL_VIEWS: readonly PanelView[] = ["terminal", "debug-console", "problems"] + +export type BottomPanelState = { + opened: boolean + activeView?: PanelView +} + +export type PanelSessionState = { + bottomPanel: BottomPanelState + rightPanelMode?: Mode +} + +export type PanelTransitionInput = { + locations: Record + sideAvailable: boolean + state: PanelSessionState +} + +const clonePanelState = (state: PanelSessionState): PanelSessionState => ({ + bottomPanel: { ...state.bottomPanel }, + rightPanelMode: state.rightPanelMode, +}) + +export const panelHost = (location: PanelLocation, sideAvailable: boolean): PanelLocation => + location === "side" && !sideAvailable ? "bottom" : location + +export const bottomPanelFallback = ( + locations: Record, + sideAvailable: boolean, +): PanelView | undefined => PANEL_VIEWS.find((view) => panelHost(locations[view], sideAvailable) === "bottom") + +export const panelIsVisible = ( + state: PanelSessionState, + view: PanelView, + location: PanelLocation, + sideAvailable: boolean, +) => { + const host = panelHost(location, sideAvailable) + return host === "bottom" + ? state.bottomPanel.opened && state.bottomPanel.activeView === view + : state.rightPanelMode === view +} + +export const revealPanel = ( + input: PanelTransitionInput, + view: PanelView, +): PanelSessionState => { + const state = clonePanelState(input.state) + const host = panelHost(input.locations[view], input.sideAvailable) + if (host === "bottom") { + state.bottomPanel = { opened: true, activeView: view } + } else { + state.rightPanelMode = view as Mode + } + return state +} + +export const togglePanel = ( + input: PanelTransitionInput, + view: PanelView, +): PanelSessionState => { + if (!panelIsVisible(input.state, view, input.locations[view], input.sideAvailable)) { + return revealPanel(input, view) + } + + const state = clonePanelState(input.state) + if (panelHost(input.locations[view], input.sideAvailable) === "bottom") { + state.bottomPanel.opened = false + } else { + state.rightPanelMode = undefined + } + return state +} + +export const toggleBottomPanel = ( + input: PanelTransitionInput, +): PanelSessionState => { + const state = clonePanelState(input.state) + if (state.bottomPanel.opened) { + state.bottomPanel.opened = false + return state + } + const fallback = bottomPanelFallback(input.locations, input.sideAvailable) + if (!fallback) return { bottomPanel: { opened: false }, rightPanelMode: state.rightPanelMode } + state.bottomPanel = { + opened: true, + activeView: + state.bottomPanel.activeView && panelHost(input.locations[state.bottomPanel.activeView], input.sideAvailable) === "bottom" + ? state.bottomPanel.activeView + : fallback, + } + return state +} + +export const movePanel = ( + input: PanelTransitionInput, + view: PanelView, + target: PanelLocation, +): { locations: Record; state: PanelSessionState } => { + if (target === "side" && !input.sideAvailable) return { locations: input.locations, state: input.state } + + const source = input.locations[view] + if (source === target) return { locations: input.locations, state: input.state } + + const locations = { ...input.locations, [view]: target } + const wasVisible = panelIsVisible(input.state, view, source, input.sideAvailable) + let state = clonePanelState(input.state) + + if (source === "bottom" && state.bottomPanel.activeView === view) { + const fallback = bottomPanelFallback(locations, input.sideAvailable) + state.bottomPanel = fallback ? { opened: state.bottomPanel.opened, activeView: fallback } : { opened: false } + } + if (source === "side" && state.rightPanelMode === view) state.rightPanelMode = undefined + + if (wasVisible) state = revealPanel({ locations, sideAvailable: input.sideAvailable, state }, view) + return { locations, state } +} + // Pure reducer for the right-side-panel mode. Kept out of the provider so the // open/close/toggle contract — most importantly "toggling the active tab closes // the panel" — is unit-testable without constructing the full LayoutProvider. diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts index 89b17d3b..14e0263a 100644 --- a/packages/app/src/context/layout.test.ts +++ b/packages/app/src/context/layout.test.ts @@ -2,10 +2,17 @@ import { describe, expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" import { createSessionKeyReader, + movePanel, + panelIsVisible, ensureSessionKey, isPanelOpen, pruneSessionKeys, + revealPanel, + toggleBottomPanel, + togglePanel, toggledPanelMode, + type PanelSessionState, + type PanelTransitionInput, } from "./layout-helpers" describe("right-side-panel mode reducer", () => { @@ -35,6 +42,88 @@ describe("right-side-panel mode reducer", () => { }) }) +describe("movable panel state machine", () => { + const locations = { + terminal: "bottom", + "debug-console": "bottom", + problems: "bottom", + } as const + const state: PanelSessionState = { bottomPanel: { opened: false } } + const input = (overrides: Partial = {}): PanelTransitionInput => ({ + locations, + sideAvailable: true, + state, + ...overrides, + }) + + test("reveal opens the correct host for bottom and side views", () => { + expect(revealPanel(input(), "terminal")).toEqual({ bottomPanel: { opened: true, activeView: "terminal" } }) + expect(revealPanel(input({ locations: { ...locations, terminal: "side" } }), "terminal")).toEqual({ + bottomPanel: { opened: false }, + rightPanelMode: "terminal", + }) + }) + + test("toggle only closes the host currently displaying the requested view", () => { + const terminalOpen = revealPanel(input(), "terminal") + expect(togglePanel(input({ state: terminalOpen }), "terminal")).toEqual({ + bottomPanel: { opened: false, activeView: "terminal" }, + }) + expect(togglePanel(input({ state: terminalOpen }), "debug-console")).toEqual({ + bottomPanel: { opened: true, activeView: "debug-console" }, + }) + }) + + test("independent bottom toggle remains closed when no view remains", () => { + const allSide = { terminal: "side", "debug-console": "side", problems: "side" } as const + expect(toggleBottomPanel(input({ locations: allSide }))).toEqual({ bottomPanel: { opened: false } }) + }) + + test("bottom toggle replaces a stale active view with a valid fallback", () => { + const locationsWithTerminalSide = { terminal: "side", "debug-console": "bottom", problems: "bottom" } as const + expect( + toggleBottomPanel(input({ locations: locationsWithTerminalSide, state: { bottomPanel: { opened: false, activeView: "terminal" } } })), + ).toEqual({ bottomPanel: { opened: true, activeView: "debug-console" } }) + }) + + test("moving a visible bottom view reveals it on the side and falls back", () => { + const opened = revealPanel(input(), "terminal") + const moved = movePanel(input({ state: opened }), "terminal", "side") + expect(moved.locations.terminal).toBe("side") + expect(moved.state).toEqual({ bottomPanel: { opened: true, activeView: "debug-console" }, rightPanelMode: "terminal" }) + }) + + test("moving the final bottom view closes the bottom panel", () => { + const onlyTerminalBottom = { terminal: "bottom", "debug-console": "side", problems: "side" } as const + const opened = revealPanel(input({ locations: onlyTerminalBottom }), "terminal") + const moved = movePanel(input({ locations: onlyTerminalBottom, state: opened }), "terminal", "side") + expect(moved.state).toEqual({ bottomPanel: { opened: false }, rightPanelMode: "terminal" }) + }) + + test("moving a visible side view to bottom clears stale side mode", () => { + const terminalSide = { ...locations, terminal: "side" } as const + const opened = revealPanel(input({ locations: terminalSide }), "terminal") + const moved = movePanel(input({ locations: terminalSide, state: opened }), "terminal", "bottom") + expect(moved.state).toEqual({ bottomPanel: { opened: true, activeView: "terminal" }, rightPanelMode: undefined }) + }) + + test("session snapshots remain isolated", () => { + const sessionA = revealPanel(input(), "terminal") + const sessionB = revealPanel(input(), "problems") + expect(panelIsVisible(sessionA, "terminal", "bottom", true)).toBe(true) + expect(panelIsVisible(sessionB, "terminal", "bottom", true)).toBe(false) + expect(sessionB.bottomPanel.activeView).toBe("problems") + }) + + test("mobile refuses a side move and reveals an old side preference in bottom", () => { + const mobile = input({ sideAvailable: false }) + expect(movePanel(mobile, "terminal", "side")).toEqual({ locations, state }) + expect(revealPanel(input({ sideAvailable: false, locations: { ...locations, terminal: "side" } }), "terminal")).toEqual({ + bottomPanel: { opened: true, activeView: "terminal" }, + }) + }) +}) + describe("layout session-key helpers", () => { test("couples touch and scroll seed in order", () => { const calls: string[] = [] diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 7344ad4b..cbc04598 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -19,8 +19,17 @@ import { createSessionKeyReader, ensureSessionKey, isPanelOpen, + movePanel, + panelHost, pruneSessionKeys, + revealPanel, + toggleBottomPanel, + togglePanel, toggledPanelMode, + type PanelLocation, + type PanelSessionState, + type PanelTransitionInput, + type PanelView, } from "./layout-helpers" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -49,6 +58,19 @@ const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] +// ── Movable panel views ─────────────────────────────────────────────────────── +// The three views that can live in the independent Bottom Panel or the session +// right panel. Other right-panel modes (DAP, PAP, review, files, ...) remain +// side-native and are deliberately excluded from this union. +export type DockPanelID = PanelView +export type DockLocation = PanelLocation +export const DOCK_PANEL_IDS: readonly DockPanelID[] = ["terminal", "debug-console", "problems"] +const DOCK_DEFAULT_LOCATION: Record = { + terminal: "bottom", + "debug-console": "bottom", + problems: "bottom", +} + export function getAvatarColors(key?: string) { if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) { return { @@ -95,6 +117,14 @@ type SessionView = { | "debug" | "im" | "oversight" + // Movable panel views can also live in the side panel. + | "terminal" + | "debug-console" + | "problems" + bottomPanel?: { + opened: boolean + activeView?: DockPanelID + } pendingMessage?: string pendingMessageAt?: number todoCollapsed?: boolean @@ -171,6 +201,8 @@ const currentRoute = (pathname: string): LayoutRoute => { return { type: "dir-new-sesssion", dir, dirBase64 } } +type SessionRightPanelMode = NonNullable + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", gate: false, @@ -303,6 +335,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( height: DEFAULT_TERMINAL_HEIGHT, opened: false, }, + // Dock-panel location (global, matching terminal.opened's global scope). Each movable panel + // (terminal / debug-console) remembers whether it lives in the bottom dock or the right side + // panel. Absent entries fall back to DOCK_DEFAULT_LOCATION at read time, so an older store with + // no `dock` field keeps working with no migration. + dock: { + location: {} as Record, + }, review: { diffStyle: "split" as ReviewDiffStyle, panelOpened: true, @@ -333,7 +372,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }), ) - // Reactive window width, used to derive the right panel px width from its stored ratio. + // Reactive viewport width is used by both resizable panel geometry and the + // side-host reachability guard for movable views. const [windowWidth, setWindowWidth] = createSignal(typeof window === "undefined" ? 1280 : window.innerWidth) if (typeof window !== "undefined") { const onResize = () => setWindowWidth(window.innerWidth) @@ -690,6 +730,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("terminal", "height", height) }, }, + dock: { + // Deprecated compatibility metadata. New UI must use view(session).panel; + // only locations and the shared Bottom Panel dimension remain global. + location(id: DockPanelID): DockLocation { + return store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id] + }, + setLocation(id: DockPanelID, location: DockLocation) { + if (!store.dock) { + setStore("dock", { location: { [id]: location } as Record }) + return + } + setStore("dock", "location", id, location) + }, + move(id: DockPanelID) { + const current = store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id] + const next: DockLocation = current === "bottom" ? "side" : "bottom" + if (!store.dock) { + setStore("dock", { location: { [id]: next } as Record }) + return + } + setStore("dock", "location", id, next) + }, + bottomCount: createMemo( + () => DOCK_PANEL_IDS.filter((id) => (store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id]) === "bottom").length, + ), + }, review: { diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), setDiffStyle(diffStyle: ReviewDiffStyle) { @@ -838,9 +904,48 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( view(sessionKey: string | Accessor) { const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) - const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) const rightPanelMode = createMemo(() => store.sessionView[key()]?.rightPanelMode) + const bottomPanel = createMemo(() => { + const current = store.sessionView[key()] + // One-way compatibility migration from the pre-panel global terminal flag. + if (current?.bottomPanel) return current.bottomPanel + if (store.terminal?.opened) return { opened: true, activeView: "terminal" as DockPanelID } + return { opened: false } as PanelSessionState["bottomPanel"] + }) + const panelLocations = createMemo( + () => + Object.fromEntries( + DOCK_PANEL_IDS.map((id) => [id, store.dock?.location?.[id] ?? DOCK_DEFAULT_LOCATION[id]]), + ) as Record, + ) + const sideAvailable = createMemo(() => windowWidth() >= 768) + + function commitPanel(next: PanelSessionState) { + const session = key() + const current = store.sessionView[session] + const bottom = next.bottomPanel + if (!current) { + setStore("sessionView", session, { scroll: {}, bottomPanel: bottom, rightPanelMode: next.rightPanelMode }) + return + } + setStore( + "sessionView", + session, + produce((draft) => { + draft.bottomPanel = bottom + draft.rightPanelMode = next.rightPanelMode + }), + ) + } + + function panelInput(): PanelTransitionInput { + return { + locations: panelLocations(), + sideAvailable: sideAvailable(), + state: { bottomPanel: bottomPanel(), rightPanelMode: rightPanelMode() }, + } + } function setRightPanelMode(next: SessionView["rightPanelMode"]) { const session = key() @@ -898,15 +1003,50 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, terminal: { - opened: terminalOpened, + // Legacy adapter for existing non-panel callers. New UI must use panel. + opened: createMemo(() => bottomPanel().opened && bottomPanel().activeView === "terminal"), open() { - setTerminalOpened(true) + commitPanel(revealPanel(panelInput(), "terminal")) }, close() { - setTerminalOpened(false) + commitPanel(togglePanel(panelInput(), "terminal")) }, toggle() { - setTerminalOpened(!terminalOpened()) + commitPanel(togglePanel(panelInput(), "terminal")) + }, + }, + panel: { + locations: panelLocations, + sideAvailable, + bottom: { + opened: createMemo(() => bottomPanel().opened), + activeView: createMemo(() => bottomPanel().activeView), + toggle() { + commitPanel(toggleBottomPanel(panelInput())) + }, + }, + location(view: DockPanelID) { + return panelHost(panelLocations()[view], sideAvailable()) + }, + viewsAt(location: DockLocation) { + return DOCK_PANEL_IDS.filter((view) => panelHost(panelLocations()[view], sideAvailable()) === location) + }, + reveal(view: DockPanelID) { + commitPanel(revealPanel(panelInput(), view)) + }, + toggle(view: DockPanelID) { + commitPanel(togglePanel(panelInput(), view)) + }, + move(view: DockPanelID, target: DockLocation) { + const input = panelInput() + const moved = movePanel(input, view, target) + if (moved.locations[view] === input.locations[view] && moved.state === input.state) return + if (!store.dock) { + setStore("dock", { location: moved.locations }) + } else { + setStore("dock", "location", view, moved.locations[view]) + } + commitPanel(moved.state) }, }, reviewPanel: { diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 292fd5f8..6aed2145 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -13,9 +13,25 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@deepagent-code/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" -import { pruneNotifications, type Notification } from "./notification-state" -export type { Notification } from "./notification-state" +type NotificationBase = { + directory?: string + session?: string + metadata?: unknown + time: number + viewed: boolean +} + +type TurnCompleteNotification = NotificationBase & { + type: "turn-complete" +} + +type ErrorNotification = NotificationBase & { + type: "error" + error: EventSessionError["properties"]["error"] +} + +export type Notification = TurnCompleteNotification | ErrorNotification type NotificationIndex = { session: { @@ -32,6 +48,16 @@ type NotificationIndex = { } } +const MAX_NOTIFICATIONS = 500 +const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 + +function pruneNotifications(list: Notification[]) { + const cutoff = Date.now() - NOTIFICATION_TTL_MS + const pruned = list.filter((n) => n.time >= cutoff) + if (pruned.length <= MAX_NOTIFICATIONS) return pruned + return pruned.slice(pruned.length - MAX_NOTIFICATIONS) +} + function createNotificationIndex(): NotificationIndex { return { session: { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 91f79cc5..423d73de 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -44,12 +44,23 @@ import { ServerConnection, useServer } from "./server" import { retry } from "@deepagent-code/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { persisted } from "@/utils/persist" -import { isFilesystemRootDir } from "@/utils/filesystem-root" import { toggleMcp } from "./global-sync/mcp" import type { SessionPlan, SessionPlanStep, SessionGoal } from "./global-sync/types" export type { SessionPlan, SessionPlanStep, SessionGoal } +// True when `dir` is a filesystem root: posix "/" or a Windows drive/UNC root ("C:\", "C:/", "\\"). +// Rooting an instance here is refused server-side (assertSafeInstanceRoot); we check on the client +// too so we never fire the doomed boot. Kept dependency-free (no node:path in the renderer). +function isFilesystemRootDir(dir: string): boolean { + const trimmed = dir.trim() + if (!trimmed) return false + const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "") + if (normalized === "") return true // was "/" or "\" (all separators) + if (/^[A-Za-z]:$/.test(normalized)) return true // "C:" (drive root after trailing-slash strip) + return false +} + type GlobalStore = { ready: boolean error?: InitError @@ -286,7 +297,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { const queue = createRefreshQueue({ paused, key: directoryKey, - accept: (directory) => !isFilesystemRootDir(directory), bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }), bootstrapInstance, }) @@ -412,8 +422,18 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { async function bootstrapInstance(directory: string) { const key = directoryKey(directory) if (!key) return + // Fail-closed against a filesystem-root directory. The server refuses to boot an instance + // rooted at "/" (assertSafeInstanceRoot — it would make the file-tool permission boundary the + // whole disk), so a stored session/route pointing at "/" would otherwise trigger an endless + // boot→fail→retry storm surfacing only as "unexpected server error". Mirror the guard here so + // the doomed request is never sent and the user gets a clear reason instead. Legacy "/" data + // (pre-guard) is the only way to reach this now that the boot path rejects it. if (isFilesystemRootDir(directory)) { - children.disposeDirectory(key) + showToast({ + variant: "error", + title: language.t("toast.project.rootRefused.title"), + description: language.t("toast.project.rootRefused.description"), + }) return } const pending = booting.get(key) diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts index cd21a182..ae34e3a1 100644 --- a/packages/app/src/context/server.test.ts +++ b/packages/app/src/context/server.test.ts @@ -118,23 +118,6 @@ describe("createServerProjects", () => { }) describe("migrateCanonicalLocalServerState", () => { - test("removes persisted filesystem-root projects and last-project pointers", () => { - expect( - migrateCanonicalLocalServerState({ - projects: { - local: [ - { worktree: "/", expanded: true }, - { worktree: "/project", expanded: true }, - ], - }, - lastProject: { local: "/", remote: "/project" }, - }), - ).toEqual({ - projects: { local: [{ worktree: "/project", expanded: true }] }, - lastProject: { remote: "/project" }, - }) - }) - test("moves an existing canonical web bucket into local scope", () => { expect( migrateCanonicalLocalServerState( diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 41f68cd6..1efe91a0 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -3,7 +3,6 @@ import { type Accessor, batch, createMemo } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import { ServerScope } from "@/utils/server-scope" -import { isFilesystemRootDir } from "@/utils/filesystem-root" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http | ServerConnection.Server @@ -33,10 +32,6 @@ function isRecord(value: unknown): value is Record { } export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) { - return removeFilesystemRootServerState(migrateCanonicalServerScope(value, canonicalLocalServer)) -} - -function migrateCanonicalServerScope(value: unknown, canonicalLocalServer?: ServerConnection.Key) { if (!canonicalLocalServer || canonicalLocalServer === "local") return value if (!isRecord(value)) return value const projects = isRecord(value.projects) ? value.projects : undefined @@ -70,31 +65,6 @@ function migrateCanonicalServerScope(value: unknown, canonicalLocalServer?: Serv return next } -function removeFilesystemRootServerState(value: unknown) { - if (!isRecord(value)) return value - const projects = isRecord(value.projects) - ? Object.fromEntries( - Object.entries(value.projects).map(([scope, entries]) => [ - scope, - Array.isArray(entries) - ? entries.filter( - (project) => - !isRecord(project) || typeof project.worktree !== "string" || !isFilesystemRootDir(project.worktree), - ) - : entries, - ]), - ) - : value.projects - const lastProject = isRecord(value.lastProject) - ? Object.fromEntries( - Object.entries(value.lastProject).filter( - ([, directory]) => typeof directory !== "string" || !isFilesystemRootDir(directory), - ), - ) - : value.lastProject - return { ...value, projects, lastProject } -} - export function createServerProjects(input: { scope: Accessor store: Store @@ -301,7 +271,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( // Normalize a stored entry to a typed connection so we can key it uniformly // (Http keys by url, Server keys by `server:`). const toConnection = (x: StoredServer): ServerConnection.Any => - typeof x === "string" ? { type: "http", http: { url: x } } : "type" in x ? x : { type: "http", http: x } + typeof x === "string" + ? { type: "http", http: { url: x } } + : "type" in x + ? x + : { type: "http", http: x } const allServers = createMemo((): Array => { return resolveServerList({ stored: store.list, props: props.servers }) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index d6f06858..d9739865 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" +import { createRoot } from "solid-js" import { ServerScope } from "@/utils/server-scope" let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] -let migrateTerminalState: (value: unknown) => unknown let _splitLeaf: typeof import("./terminal")._splitLeaf let _collapseLeaf: typeof import("./terminal")._collapseLeaf let _neighborLeafId: typeof import("./terminal")._neighborLeafId @@ -11,6 +11,7 @@ let _removePtyFromTree: typeof import("./terminal")._removePtyFromTree let _treeDepth: typeof import("./terminal")._treeDepth let _getLeaves: typeof import("./terminal")._getLeaves let _clonePaneTree: typeof import("./terminal")._clonePaneTree +let createTerminalSession: (typeof import("./terminal").TerminalTesting)["createWorkspaceTerminalSession"] beforeAll(async () => { mock.module("@solidjs/router", () => ({ @@ -32,7 +33,6 @@ beforeAll(async () => { const mod = await import("./terminal") getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys - migrateTerminalState = mod.migrateTerminalState _splitLeaf = mod._splitLeaf _collapseLeaf = mod._collapseLeaf _neighborLeafId = mod._neighborLeafId @@ -40,6 +40,7 @@ beforeAll(async () => { _treeDepth = mod._treeDepth _getLeaves = mod._getLeaves _clonePaneTree = mod._clonePaneTree + createTerminalSession = mod.TerminalTesting.createWorkspaceTerminalSession }) describe("getWorkspaceTerminalCacheKey", () => { @@ -67,122 +68,6 @@ describe("getLegacyTerminalStorageKeys", () => { }) }) -type Leaf = { kind: "leaf"; id: string; activeId?: string; ptys: string[] } -type Split = { kind: "split"; id: string; dir: string; sizes: [number, number]; children: [Node, Node] } -type Node = Leaf | Split -type Store = { root: Node; all: Array>; focusedPaneId: string } - -const asStore = (value: unknown) => value as Store -const leaves = (node: Node): Leaf[] => - node.kind === "leaf" ? [node] : [...leaves(node.children[0]), ...leaves(node.children[1])] - -describe("migrateTerminalState", () => { - test("drops invalid terminals and upgrades the flat v2 shape to a single leaf", () => { - const store = asStore( - migrateTerminalState({ - active: "missing", - all: [ - null, - { id: "one", title: "Terminal 2" }, - { id: "one", title: "duplicate", titleNumber: 9 }, - { id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 }, - { title: "no-id" }, - ], - }), - ) - - expect(store.all).toEqual([ - { id: "one", title: "Terminal 2", titleNumber: 2 }, - { id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 }, - ]) - expect(store.root.kind).toBe("leaf") - const root = store.root as Leaf - expect(root.ptys).toEqual(["one", "two"]) - // active "missing" is invalid → the leaf owns no such pty, so activeId is dropped. - expect(root.activeId).toBeUndefined() - expect(store.focusedPaneId).toBe(root.id) - }) - - test("keeps a valid active id when upgrading the flat shape", () => { - const store = asStore( - migrateTerminalState({ - active: "two", - all: [ - { id: "one", title: "Terminal 1" }, - { id: "two", title: "shell", titleNumber: 7 }, - ], - }), - ) - - expect(store.all).toEqual([ - { id: "one", title: "Terminal 1", titleNumber: 1 }, - { id: "two", title: "shell", titleNumber: 7 }, - ]) - const root = store.root as Leaf - expect(root.activeId).toBe("two") - expect(root.ptys).toEqual(["one", "two"]) - }) - - test("validates and reconciles a v3 pane tree, dropping dead ptys and re-homing orphans", () => { - const store = asStore( - migrateTerminalState({ - focusedPaneId: "leaf-b", - all: [ - { id: "a", title: "a", titleNumber: 1 }, - { id: "b", title: "b", titleNumber: 2 }, - { id: "orphan", title: "orphan", titleNumber: 3 }, - ], - root: { - kind: "split", - id: "split-1", - dir: "horizontal", - sizes: [0.7, 0.3], - children: [ - { kind: "leaf", id: "leaf-a", activeId: "a", ptys: ["a", "dead"] }, - { kind: "leaf", id: "leaf-b", activeId: "gone", ptys: ["b", "gone"] }, - ], - }, - }), - ) - - expect(store.root.kind).toBe("split") - const all = leaves(store.root) - const a = all.find((l) => l.id === "leaf-a")! - const b = all.find((l) => l.id === "leaf-b")! - // dead pty removed from leaf-a; orphan re-homed onto the first leaf. - expect(a.ptys).toEqual(["a", "orphan"]) - expect(a.activeId).toBe("a") - // "gone" was invalid → dropped; activeId falls back to first surviving pty. - expect(b.ptys).toEqual(["b"]) - expect(b.activeId).toBe("b") - expect(store.focusedPaneId).toBe("leaf-b") - }) - - test("collapses a v3 split whose child leaves both lose all ptys back to a single leaf", () => { - const store = asStore( - migrateTerminalState({ - all: [{ id: "keep", title: "keep", titleNumber: 1 }], - root: { - kind: "split", - id: "split-1", - dir: "vertical", - sizes: [0.5, 0.5], - children: [ - { kind: "leaf", id: "leaf-a", activeId: "dead-1", ptys: ["dead-1"] }, - { kind: "leaf", id: "leaf-b", activeId: "dead-2", ptys: ["dead-2"] }, - ], - }, - }), - ) - - // Both children emptied → split collapses; the surviving leaf adopts the orphan "keep". - expect(store.root.kind).toBe("leaf") - const root = store.root as Leaf - expect(root.ptys).toEqual(["keep"]) - expect(store.focusedPaneId).toBe(root.id) - }) -}) - // ── V3.7 pane pure-function regression tests (P2 补强) ───────────────────────── // These guard splitLeaf / collapseLeaf / neighborLeafId / removePtyFromTree so // structural changes to the pane tree helpers stay visible immediately. @@ -310,3 +195,161 @@ describe("clonePaneTree (circular-structure regression)", () => { expect((cloned as PS).children[0]).not.toBe(unwrap(proxyLeaf)) }) }) + +function terminalHarness( + create: () => Promise<{ data?: { id: string; title: string } }>, + runtime: { id: () => string | undefined; ensure: () => Promise } = { + id: () => "runtime-1", + ensure: async () => undefined, + }, +) { + const sdk = { + directory: "/repo", + url: "http://localhost:4096", + client: { + pty: { + create, + remove: async () => ({ response: new Response(null, { status: 200 }) }), + update: async () => ({ response: new Response(null, { status: 200 }) }), + }, + }, + event: { on: () => () => undefined }, + } as unknown as Parameters[0] + return createRoot((dispose) => ({ + session: createTerminalSession(sdk, runtime), + dispose, + })) +} + +describe("runtime terminal controller", () => { + test("keeps frontend tab identity separate from the server PTY handle", async () => { + const harness = terminalHarness(async () => ({ data: { id: "pty-server-1", title: "Terminal 1" } })) + try { + expect(await harness.session.new()).toBeTrue() + const tab = harness.session.all()[0]! + expect(tab.id).not.toBe(tab.ptyId) + expect(tab.ptyId).toBe("pty-server-1") + expect((harness.session.root() as PL).ptys).toEqual([tab.id]) + } finally { + harness.dispose() + } + }) + + test("starts PTY creation without waiting for the advisory runtime check", async () => { + let ensureCalls = 0 + let createCalls = 0 + const harness = terminalHarness( + async () => { + createCalls += 1 + return { data: { id: "pty-server-1", title: "Terminal 1" } } + }, + { + id: () => undefined, + ensure: () => { + ensureCalls += 1 + return new Promise(() => undefined) + }, + }, + ) + try { + expect(await harness.session.new()).toBeTrue() + expect(ensureCalls).toBe(1) + expect(createCalls).toBe(1) + } finally { + harness.dispose() + } + }) + + test("does not commit a split when server creation fails", async () => { + let fail = false + let sequence = 0 + const harness = terminalHarness(async () => { + if (fail) throw new Error("spawn failed") + sequence += 1 + return { data: { id: `pty-${sequence}`, title: `Terminal ${sequence}` } } + }) + try { + await harness.session.new() + const paneId = harness.session.focusedPaneId() + harness.session.setPaneBounds(paneId, { width: 600, height: 300 }) + const before = JSON.stringify(harness.session.root()) + fail = true + + expect(await harness.session.split("horizontal")).toBeFalse() + expect(JSON.stringify(harness.session.root())).toBe(before) + expect(harness.session.all()).toHaveLength(1) + expect(harness.session.createError()?.message).toBe("spawn failed") + } finally { + harness.dispose() + } + }) + + test("keeps every pane populated across consecutive nested splits", async () => { + let sequence = 0 + const harness = terminalHarness(async () => { + sequence += 1 + return { data: { id: `pty-${sequence}`, title: `Terminal ${sequence}` } } + }) + try { + expect(harness.session.canSplit(harness.session.focusedPaneId())).toBeFalse() + await harness.session.new() + harness.session.setPaneBounds(harness.session.focusedPaneId(), { width: 1_600, height: 400 }) + expect(await harness.session.split("horizontal")).toBeTrue() + + harness.session.setPaneBounds(harness.session.focusedPaneId(), { width: 800, height: 400 }) + expect(await harness.session.split("horizontal")).toBeTrue() + + harness.session.setPaneBounds(harness.session.focusedPaneId(), { width: 530, height: 400 }) + expect(await harness.session.split("horizontal")).toBeTrue() + + const leaves = _getLeaves(harness.session.root()) + expect(leaves).toHaveLength(4) + expect(leaves.every((leaf) => leaf.ptys.length === 1 && leaf.activeId === leaf.ptys[0])).toBeTrue() + expect(new Set(leaves.flatMap((leaf) => leaf.ptys))).toEqual(new Set(harness.session.all().map((pty) => pty.id))) + } finally { + harness.dispose() + } + }) + + test("drops a stale create completion after the server runtime changes", async () => { + let markStarted: (() => void) | undefined + const started = new Promise((resolve) => { + markStarted = resolve + }) + let resolveCreate: ((value: { data: { id: string; title: string } }) => void) | undefined + const harness = terminalHarness( + () => + new Promise((resolve) => { + markStarted?.() + resolveCreate = resolve + }), + ) + try { + const create = harness.session.new() + await started + harness.session.resetRuntime() + resolveCreate?.({ data: { id: "pty-stale", title: "Terminal 1" } }) + + expect(await create).toBeFalse() + expect(harness.session.all()).toEqual([]) + expect(harness.session.root().kind).toBe("leaf") + expect(harness.session.closeRequest()).toBe(0) + } finally { + harness.dispose() + } + }) + + test("requests panel close only when the user closes the final tab", async () => { + const harness = terminalHarness(async () => ({ data: { id: "pty-server-1", title: "Terminal 1" } })) + try { + await harness.session.new() + const id = harness.session.all()[0]!.id + expect(harness.session.closeRequest()).toBe(0) + + await harness.session.close(id) + expect(harness.session.closeRequest()).toBe(1) + } finally { + harness.dispose() + } + }) +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index adf1569c..ee2f91d6 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -1,87 +1,93 @@ -import { createStore, produce, reconcile } from "solid-js/store" +import { createStore } from "solid-js/store" import { createSimpleContext } from "@deepagent-code/ui/context" -import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" -import { useParams } from "@solidjs/router" +import { batch, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { useSDK } from "./sdk" -import type { Platform } from "./platform" +import { usePlatform, type Platform } from "./platform" import { useServer } from "./server" import { defaultTitle, titleNumber } from "./terminal-title" import { withServerAbortRetry } from "./terminal-retry" -import { Persist, persisted, removePersisted } from "@/utils/persist" +import { Persist, removePersisted } from "@/utils/persist" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" import { uuid } from "@/utils/uuid" import { formatServerError } from "@/utils/server-errors" -// Surface a terminal-create failure to the user. The most common cause is a -// stale working directory (the server replies 400 "Working directory no longer -// exists: …"); previously this only hit the console and the terminal silently -// never appeared. Imported lazily so the toast module isn't pulled into the -// terminal context's initial graph. -async function notifyTerminalCreateFailed(error: unknown) { - try { - const { showToast } = await import("@/utils/toast") - showToast({ variant: "error", title: "Failed to open terminal", description: formatServerError(error) }) - } catch { - // toast is best-effort; the console.error above is the durable record - } -} - -// ─── Pane tree types (V3.7 Phase 4.2) ──────────────────────────────────────── - export type PaneId = string -/** Leaf node: owns an ordered list of PTYs, one of which is active */ export type PaneLeaf = { kind: "leaf" id: PaneId activeId: string | undefined - ptys: string[] // PTY ids owned by this leaf (ordered) + ptys: string[] } -/** Split node: two children arranged horizontally or vertically */ export type PaneSplit = { kind: "split" id: PaneId dir: "horizontal" | "vertical" - /** sizes[0] + sizes[1] === 1.0 */ sizes: readonly [number, number] children: readonly [PaneNode, PaneNode] } export type PaneNode = PaneLeaf | PaneSplit -/** Top-level store shape (V3.7) */ +export type TerminalStatus = "connecting" | "ready" | "reconnecting" | "exited" | "error" +export type TerminalOperation = "create" | "connect" | "resize" | "rename" | "close" + +export type TerminalFailure = { + operation: TerminalOperation + code: string + message: string + status?: number + ptyId?: string + directory?: string + runtimeId?: string +} + +export type LocalPTY = { + /** Stable frontend tab identity. Never sent to the PTY server. */ + id: string + /** Process handle owned by the current server runtime. */ + ptyId: string + title: string + titleNumber: number + status: TerminalStatus + error?: TerminalFailure +} + export type TerminalStore = { root: PaneNode all: LocalPTY[] focusedPaneId: PaneId } -const MAX_SPLIT_DEPTH = 3 +export type PaneBounds = { width: number; height: number } -// ─── Pure tree helpers ──────────────────────────────────────────────────────── +export const MAX_SPLIT_DEPTH = 8 +export const MAX_TERMINAL_PANES = 8 +export const MIN_TERMINAL_PANE_WIDTH = 160 +export const MIN_TERMINAL_PANE_HEIGHT = 160 + +const MAX_TERMINAL_SESSIONS = 20 +const WORKSPACE_KEY = "__workspace__" +const RUNTIME_POLL_MS = 5_000 function findLeafById(root: PaneNode, id: PaneId): PaneLeaf | undefined { if (root.kind === "leaf") return root.id === id ? root : undefined return findLeafById(root.children[0], id) ?? findLeafById(root.children[1], id) } -function findLeafForPty(root: PaneNode, ptyId: string): PaneLeaf | undefined { - if (root.kind === "leaf") return root.ptys.includes(ptyId) ? root : undefined - return findLeafForPty(root.children[0], ptyId) ?? findLeafForPty(root.children[1], ptyId) +function findLeafForPty(root: PaneNode, id: string): PaneLeaf | undefined { + if (root.kind === "leaf") return root.ptys.includes(id) ? root : undefined + return findLeafForPty(root.children[0], id) ?? findLeafForPty(root.children[1], id) } -function findParent( - root: PaneNode, - id: PaneId, -): { parent: PaneSplit; index: 0 | 1 } | undefined { - if (root.kind === "leaf") return undefined - for (let i = 0; i < 2; i++) { - if (root.children[i].id === id) return { parent: root, index: i as 0 | 1 } - const found = findParent(root.children[i], id) +function findParent(root: PaneNode, id: PaneId): { parent: PaneSplit; index: 0 | 1 } | undefined { + if (root.kind === "leaf") return + for (const index of [0, 1] as const) { + if (root.children[index].id === id) return { parent: root, index } + const found = findParent(root.children[index], id) if (found) return found } - return undefined } function replaceNode(root: PaneNode, id: PaneId, replacement: PaneNode): PaneNode { @@ -89,10 +95,7 @@ function replaceNode(root: PaneNode, id: PaneId, replacement: PaneNode): PaneNod if (root.kind === "leaf") return root return { ...root, - children: [ - replaceNode(root.children[0], id, replacement), - replaceNode(root.children[1], id, replacement), - ] as [PaneNode, PaneNode], + children: [replaceNode(root.children[0], id, replacement), replaceNode(root.children[1], id, replacement)], } } @@ -106,373 +109,179 @@ function treeDepth(root: PaneNode): number { return 1 + Math.max(treeDepth(root.children[0]), treeDepth(root.children[1])) } -/** Level of a leaf: 1 for the root leaf, +1 per enclosing split. */ function leafLevel(root: PaneNode, id: PaneId, depth = 1): number | undefined { if (root.kind === "leaf") return root.id === id ? depth : undefined return leafLevel(root.children[0], id, depth + 1) ?? leafLevel(root.children[1], id, depth + 1) } -/** Descend to the leaf on the given side (0 = first/top-left, 1 = last/bottom-right). */ function edgeLeaf(node: PaneNode, side: 0 | 1): PaneLeaf { - let current = node - while (current.kind === "split") current = current.children[side] - return current -} - -/** - * Deep-clone a pane tree into plain objects, detaching every node from the - * reactive store proxy. The tree helpers (splitLeaf/collapseLeaf) reuse an - * existing node — which may still be a live store proxy — inside the tree they - * return. Committing that back through `reconcile` re-parents a proxy under a - * fresh object and creates a self-reference: `JSON.stringify` in the persist - * layer then throws "circular structure" and reconcile recurses to a stack - * overflow. Cloning here guarantees the committed value is a plain, acyclic - * graph with fresh object identities. - */ + if (node.kind === "leaf") return node + return edgeLeaf(node.children[side], side) +} + function clonePaneTree(node: PaneNode): PaneNode { - if (node.kind === "leaf") { - return { kind: "leaf", id: node.id, activeId: node.activeId, ptys: [...node.ptys] } - } + if (node.kind === "leaf") return { kind: "leaf", id: node.id, activeId: node.activeId, ptys: [...node.ptys] } return { kind: "split", id: node.id, dir: node.dir, sizes: [node.sizes[0], node.sizes[1]], - children: [clonePaneTree(node.children[0]), clonePaneTree(node.children[1])] as [PaneNode, PaneNode], + children: [clonePaneTree(node.children[0]), clonePaneTree(node.children[1])], } } -/** Replace a leaf's fields, returning a new tree. */ function updateLeaf(root: PaneNode, id: PaneId, patch: Partial>): PaneNode { if (root.kind === "leaf") return root.id === id ? { ...root, ...patch } : root return { ...root, - children: [updateLeaf(root.children[0], id, patch), updateLeaf(root.children[1], id, patch)] as [ - PaneNode, - PaneNode, - ], + children: [updateLeaf(root.children[0], id, patch), updateLeaf(root.children[1], id, patch)], } } -/** Split a leaf into two, keeping its ptys on the first child and the new pty on the second. */ -function splitLeaf( - root: PaneNode, - leafId: PaneId, - dir: "horizontal" | "vertical", - newLeaf: PaneLeaf, -): PaneNode { +function splitLeaf(root: PaneNode, leafId: PaneId, dir: PaneSplit["dir"], newLeaf: PaneLeaf): PaneNode { const leaf = findLeafById(root, leafId) if (!leaf) return root - const split: PaneSplit = { + return replaceNode(root, leafId, { kind: "split", id: uuid(), dir, sizes: [0.5, 0.5], children: [leaf, newLeaf], - } - return replaceNode(root, leafId, split) + }) } -// ── @internal: exported thin wrappers for unit tests only ────────────────────── -/** @internal */ export const _splitLeaf = (root: PaneNode, leafId: PaneId, dir: "horizontal" | "vertical", newLeaf: PaneLeaf) => splitLeaf(root, leafId, dir, newLeaf) -/** @internal */ export const _collapseLeaf = (root: PaneNode, leafId: PaneId) => collapseLeaf(root, leafId) -/** @internal */ export const _neighborLeafId = (root: PaneNode, focusedId: PaneId, dir: "left" | "right" | "up" | "down") => neighborLeafId(root, focusedId, dir) -/** @internal */ export const _removePtyFromTree = (root: PaneNode, ptyId: string) => removePtyFromTree(root, ptyId) -/** @internal */ export const _treeDepth = (root: PaneNode) => treeDepth(root) -/** @internal */ export const _getLeaves = (root: PaneNode) => getLeaves(root) -/** @internal */ export const _clonePaneTree = (root: PaneNode) => clonePaneTree(root) - -/** Remove a pty from whatever leaf owns it; collapse the leaf into its sibling when it empties. */ -function removePtyFromTree(root: PaneNode, ptyId: string): PaneNode { - const leaf = findLeafForPty(root, ptyId) - if (!leaf) return root - - const nextPtys = leaf.ptys.filter((p) => p !== ptyId) - const nextActive = - leaf.activeId === ptyId - ? (() => { - const idx = leaf.ptys.indexOf(ptyId) - return nextPtys[idx] ?? nextPtys[idx - 1] ?? nextPtys[0] - })() - : leaf.activeId - - if (nextPtys.length > 0) { - return updateLeaf(root, leaf.id, { ptys: nextPtys, activeId: nextActive }) - } - - // Leaf is now empty: collapse it into its sibling (or clear it if it is the root). - return collapseLeaf(root, leaf.id) +function balanceSplits(root: PaneNode, dir: PaneSplit["dir"]): PaneNode { + if (root.kind === "leaf") return root + const children = [balanceSplits(root.children[0], dir), balanceSplits(root.children[1], dir)] as const + if (root.dir !== dir) return { ...root, children } + const first = getLeaves(children[0]).length + const second = getLeaves(children[1]).length + return { ...root, sizes: [first / (first + second), second / (first + second)], children } } -/** Remove a leaf, replacing its parent split with the surviving sibling. Root leaf is emptied in place. */ function collapseLeaf(root: PaneNode, leafId: PaneId): PaneNode { const parent = findParent(root, leafId) if (!parent) { - // Root leaf: keep an empty leaf so the tree always has one. if (root.kind === "leaf" && root.id === leafId) return { ...root, ptys: [], activeId: undefined } return root } - const sibling = parent.parent.children[parent.index === 0 ? 1 : 0] - return replaceNode(root, parent.parent.id, sibling) + return replaceNode(root, parent.parent.id, parent.parent.children[parent.index === 0 ? 1 : 0]) } -/** Reorder a pty within its owning leaf. */ -function reorderPtyInLeaf(root: PaneNode, ptyId: string, toIndex: number): PaneNode { - const leaf = findLeafForPty(root, ptyId) +function removePtyFromTree(root: PaneNode, id: string): PaneNode { + const leaf = findLeafForPty(root, id) if (!leaf) return root - const from = leaf.ptys.indexOf(ptyId) - if (from === -1) return root - const next = leaf.ptys.slice() - next.splice(from, 1) - next.splice(Math.max(0, Math.min(toIndex, next.length)), 0, ptyId) - return updateLeaf(root, leaf.id, { ptys: next }) -} - -/** Compute the neighbouring leaf id in a direction, tiling-wm style. */ -function neighborLeafId(root: PaneNode, focusedId: PaneId, dir: "left" | "right" | "up" | "down"): PaneId | undefined { - const wantDir: PaneSplit["dir"] = dir === "left" || dir === "right" ? "horizontal" : "vertical" - const towardSecond = dir === "right" || dir === "down" - let currentId = focusedId - for (;;) { - const p = findParent(root, currentId) - if (!p) return undefined - if (p.parent.dir === wantDir) { - if (towardSecond && p.index === 0) return edgeLeaf(p.parent.children[1], 0).id - if (!towardSecond && p.index === 1) return edgeLeaf(p.parent.children[0], 1).id - } - currentId = p.parent.id - } + const ptys = leaf.ptys.filter((item) => item !== id) + const activeId = + leaf.activeId === id ? (ptys[leaf.ptys.indexOf(id)] ?? ptys[leaf.ptys.indexOf(id) - 1] ?? ptys[0]) : leaf.activeId + if (ptys.length) return updateLeaf(root, leaf.id, { ptys, activeId }) + return collapseLeaf(root, leaf.id) } -/** Swap a pty id inside its owning leaf (used when a pty is recreated via clone). */ -function replacePtyIdInTree(root: PaneNode, oldId: string, newId: string): PaneNode { - const leaf = findLeafForPty(root, oldId) +function reorderPtyInLeaf(root: PaneNode, id: string, toIndex: number): PaneNode { + const leaf = findLeafForPty(root, id) if (!leaf) return root - return updateLeaf(root, leaf.id, { - ptys: leaf.ptys.map((p) => (p === oldId ? newId : p)), - activeId: leaf.activeId === oldId ? newId : leaf.activeId, - }) + const from = leaf.ptys.indexOf(id) + if (from === -1) return root + const ptys = leaf.ptys.slice() + ptys.splice(from, 1) + ptys.splice(Math.max(0, Math.min(toIndex, ptys.length)), 0, id) + return updateLeaf(root, leaf.id, { ptys }) } -/** Move a pty from its current leaf to another leaf; collapse the source if it empties. */ -function movePtyBetweenLeaves(root: PaneNode, ptyId: string, targetLeafId: PaneId): PaneNode { - const source = findLeafForPty(root, ptyId) +function movePtyBetweenLeaves(root: PaneNode, id: string, targetLeafId: PaneId): PaneNode { + const source = findLeafForPty(root, id) const target = findLeafById(root, targetLeafId) if (!source || !target || source.id === target.id) return root - - const withoutSource = removePtyFromTree(root, ptyId) - // removePtyFromTree may have collapsed the source; the target leaf survives regardless. - const stillTarget = findLeafById(withoutSource, targetLeafId) - if (!stillTarget) return withoutSource - const nextPtys = [...stillTarget.ptys, ptyId] - return updateLeaf(withoutSource, targetLeafId, { ptys: nextPtys, activeId: ptyId }) + const withoutSource = removePtyFromTree(root, id) + const nextTarget = findLeafById(withoutSource, targetLeafId) + if (!nextTarget) return withoutSource + return updateLeaf(withoutSource, targetLeafId, { ptys: [...nextTarget.ptys, id], activeId: id }) } -/** Set a split node's sizes. */ -function updateSplitSizes(root: PaneNode, splitId: PaneId, next: readonly [number, number]): PaneNode { +function updateSplitSizes(root: PaneNode, splitId: PaneId, sizes: readonly [number, number]): PaneNode { if (root.kind === "leaf") return root - if (root.id === splitId) return { ...root, sizes: next } + if (root.id === splitId) return { ...root, sizes } return { ...root, - children: [ - updateSplitSizes(root.children[0], splitId, next), - updateSplitSizes(root.children[1], splitId, next), - ] as [PaneNode, PaneNode], - } -} - -// ─── Migration ──────────────────────────────────────────────────────────────── - -/** Upgrade old flat {active,all} state to the pane-tree TerminalStore */ -export function migrateToPaneModel(old: { - active?: string - all: LocalPTY[] -}): TerminalStore { - const rootId = uuid() - return { - all: old.all, - focusedPaneId: rootId, - root: { - kind: "leaf", - id: rootId, - activeId: old.active, - ptys: old.all.map((p) => p.id), - }, - } -} - -export type LocalPTY = { - id: string - title: string - titleNumber: number - rows?: number - cols?: number - buffer?: string - scrollY?: number - cursor?: number -} - -const WORKSPACE_KEY = "__workspace__" -const MAX_TERMINAL_SESSIONS = 20 - -function record(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function text(value: unknown) { - return typeof value === "string" ? value : undefined -} - -function num(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? value : undefined -} - -function numberFromTitle(title: string) { - return titleNumber(title, MAX_TERMINAL_SESSIONS) -} - -function pty(value: unknown): LocalPTY | undefined { - if (!record(value)) return - - const id = text(value.id) - if (!id) return - - const title = text(value.title) ?? "" - const number = num(value.titleNumber) - const rows = num(value.rows) - const cols = num(value.cols) - const buffer = text(value.buffer) - const scrollY = num(value.scrollY) - const cursor = num(value.cursor) - - return { - id, - title, - titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0), - ...(rows !== undefined ? { rows } : {}), - ...(cols !== undefined ? { cols } : {}), - ...(buffer !== undefined ? { buffer } : {}), - ...(scrollY !== undefined ? { scrollY } : {}), - ...(cursor !== undefined ? { cursor } : {}), + children: [updateSplitSizes(root.children[0], splitId, sizes), updateSplitSizes(root.children[1], splitId, sizes)], } } -function normalizePtys(value: unknown): { all: LocalPTY[]; active?: string } { - const seen = new Set() - const record_ = record(value) ? value : {} - const all = (Array.isArray(record_.all) ? record_.all : []).flatMap((item) => { - const next = pty(item) - if (!next || seen.has(next.id)) return [] - seen.add(next.id) - return [next] - }) - const active = text(record_.active) - return { all, active: active && seen.has(active) ? active : undefined } -} - -function sizes(value: unknown): readonly [number, number] { - if ( - Array.isArray(value) && - typeof value[0] === "number" && - typeof value[1] === "number" && - value[0] > 0 && - value[1] > 0 - ) { - const total = value[0] + value[1] - if (total > 0) return [value[0] / total, value[1] / total] - } - return [0.5, 0.5] -} - -/** Parse a persisted pane node, keeping only ptys that exist in `valid`. */ -function paneNode(value: unknown, valid: Set): PaneNode | undefined { - if (!record(value)) return - const id = text(value.id) - if (!id) return - - if (value.kind === "split") { - const rawChildren = Array.isArray(value.children) ? value.children : [] - const first = paneNode(rawChildren[0], valid) - const second = paneNode(rawChildren[1], valid) - if (first && second) { - const dir = value.dir === "vertical" ? "vertical" : "horizontal" - return { kind: "split", id, dir, sizes: sizes(value.sizes), children: [first, second] } +function neighborLeafId(root: PaneNode, focusedId: PaneId, dir: "left" | "right" | "up" | "down") { + const wanted = dir === "left" || dir === "right" ? "horizontal" : "vertical" + const second = dir === "right" || dir === "down" + let current = focusedId + for (;;) { + const parent = findParent(root, current) + if (!parent) return + if (parent.parent.dir === wanted) { + if (second && parent.index === 0) return edgeLeaf(parent.parent.children[1], 0).id + if (!second && parent.index === 1) return edgeLeaf(parent.parent.children[0], 1).id } - // Collapse to whichever child survived. - return first ?? second + current = parent.parent.id } - - const ptys = (Array.isArray(value.ptys) ? value.ptys : []).flatMap((p) => - typeof p === "string" && valid.has(p) ? [p] : [], - ) - const activeCandidate = text(value.activeId) - const activeId = activeCandidate && ptys.includes(activeCandidate) ? activeCandidate : ptys[0] - return { kind: "leaf", id, activeId, ptys } } -/** Collapse leaves that hold no ptys into their sibling; a single empty root leaf survives. */ -function pruneEmptyLeaves(node: PaneNode): PaneNode { - if (node.kind === "leaf") return node - const first = pruneEmptyLeaves(node.children[0]) - const second = pruneEmptyLeaves(node.children[1]) - const firstEmpty = first.kind === "leaf" && first.ptys.length === 0 - const secondEmpty = second.kind === "leaf" && second.ptys.length === 0 - if (firstEmpty && !secondEmpty) return second - if (secondEmpty && !firstEmpty) return first - if (firstEmpty && secondEmpty) return first - return { ...node, children: [first, second] } -} - -/** Reconcile a parsed tree with the authoritative pty list: assign orphans, - * prune stale empty leaves, and guarantee at least one leaf. */ -function reconcileTree(root: PaneNode | undefined, all: LocalPTY[]): PaneNode { - const assigned = new Set() - if (root) for (const leaf of getLeaves(root)) for (const p of leaf.ptys) assigned.add(p) - const orphans = all.filter((p) => !assigned.has(p.id)).map((p) => p.id) - - if (!root) { - return { kind: "leaf", id: uuid(), activeId: all[0]?.id, ptys: all.map((p) => p.id) } - } +/** @internal */ export const _splitLeaf = splitLeaf +/** @internal */ export const _collapseLeaf = collapseLeaf +/** @internal */ export const _neighborLeafId = neighborLeafId +/** @internal */ export const _removePtyFromTree = removePtyFromTree +/** @internal */ export const _treeDepth = treeDepth +/** @internal */ export const _getLeaves = getLeaves +/** @internal */ export const _clonePaneTree = clonePaneTree - root = pruneEmptyLeaves(root) - - if (orphans.length) { - const leaves = getLeaves(root) - const target = leaves[0] - if (target) { - const nextPtys = [...target.ptys, ...orphans] - root = replaceNode(root, target.id, { - ...target, - ptys: nextPtys, - activeId: target.activeId ?? nextPtys[0], - }) - } +function errorStatus(error: unknown) { + if (error instanceof Error && error.cause && typeof error.cause === "object") { + const status = (error.cause as { status?: unknown }).status + if (typeof status === "number") return status } - return root -} - -function migrateStore(value: Record): TerminalStore { - const { all, active } = normalizePtys(value) - const validIds = new Set(all.map((p) => p.id)) - - if (value.root) { - // v3 format: validate + reconcile. - const parsed = paneNode(value.root, validIds) - const root = reconcileTree(parsed, all) - const leaves = getLeaves(root) - const focusedRaw = text(value.focusedPaneId) - const focusedPaneId = - focusedRaw && leaves.some((l) => l.id === focusedRaw) ? focusedRaw : leaves[0]!.id - return { root, all, focusedPaneId } + if (!error || typeof error !== "object") return + const response = (error as { response?: unknown }).response + if (response instanceof Response) return response.status + const status = (error as { status?: unknown }).status + return typeof status === "number" ? status : undefined +} + +export function terminalFailure(input: { + operation: TerminalOperation + error: unknown + status?: number + ptyId?: string + directory?: string + runtimeId?: string +}): TerminalFailure { + const status = input.status ?? errorStatus(input.error) + const code = + status === 404 + ? "PTY_NOT_FOUND" + : status === 403 + ? "PTY_FORBIDDEN" + : status === 503 + ? "SERVER_RESTARTING" + : `PTY_${input.operation.toUpperCase()}_FAILED` + return { + operation: input.operation, + code, + message: formatServerError(input.error, undefined, `Terminal ${input.operation} failed`), + ...(status !== undefined ? { status } : {}), + ...(input.ptyId ? { ptyId: input.ptyId } : {}), + ...(input.directory ? { directory: input.directory } : {}), + ...(input.runtimeId ? { runtimeId: input.runtimeId } : {}), } +} - // v2 (old flat) format: upgrade to a single leaf. - return migrateToPaneModel({ active, all }) +function logFailure(failure: TerminalFailure) { + const log = failure.status === 404 ? console.info : console.error + log("[terminal] operation failed", failure) } -export function migrateTerminalState(value: unknown) { - if (!record(value)) return value - return migrateStore(value) +function notifyCreateFailed(failure: TerminalFailure) { + return import("@/utils/toast") + .then(({ showToast }) => + showToast({ variant: "error", title: "Failed to open terminal", description: failure.message }), + ) + .catch(() => undefined) } export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScopeValue = ServerScope.local) { @@ -484,28 +293,25 @@ export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: stri return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] } -type TerminalSession = ReturnType - -type TerminalCacheEntry = { - value: TerminalSession - dispose: VoidFunction +function terminalPersistTarget(scope: ServerScopeValue, dir: string) { + return Persist.serverWorkspace(scope, dir, "terminal") } -const caches = new Set>() - -const trimTerminal = (pty: LocalPTY) => { - if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty - return { - ...pty, - buffer: undefined, - cursor: undefined, - scrollY: undefined, - } +function removeTerminalPersistence( + dir: string, + sessionIDs: string[] | undefined, + platform: Platform, + scope: ServerScopeValue, +) { + removePersisted(terminalPersistTarget(scope, dir), platform) + if (scope !== ServerScope.local) return + const keys = new Set(getLegacyTerminalStorageKeys(dir)) + for (const id of sessionIDs ?? []) for (const key of getLegacyTerminalStorageKeys(dir, id)) keys.add(key) + for (const key of keys) removePersisted({ key }, platform) } -function terminalPersistTarget(scope: ServerScopeValue, dir: string, legacy?: string[]) { - return Persist.serverWorkspace(scope, dir, "terminal", legacy) -} +type TerminalSession = ReturnType +const sessions = new Set<{ dir: string; scope: ServerScopeValue; value: TerminalSession }>() export function clearWorkspaceTerminals( dir: string, @@ -513,374 +319,405 @@ export function clearWorkspaceTerminals( platform?: Platform, scope: ServerScopeValue = ServerScope.local, ) { - const key = getWorkspaceTerminalCacheKey(dir, scope) - for (const cache of caches) { - const entry = cache.get(key) - entry?.value.clear() - } - - void removePersisted(terminalPersistTarget(scope, dir), platform) - - if (scope !== ServerScope.local) return - const legacy = new Set(getLegacyTerminalStorageKeys(dir)) - for (const id of sessionIDs ?? []) { - for (const key of getLegacyTerminalStorageKeys(dir, id)) { - legacy.add(key) - } - } - for (const key of legacy) { - void removePersisted({ key }, platform) + for (const entry of sessions) { + if (entry.dir === dir && entry.scope === scope) entry.value.clear() } + if (platform) removeTerminalPersistence(dir, sessionIDs, platform, scope) } function createWorkspaceTerminalSession( sdk: ReturnType, - dir: string, - scope: ServerScopeValue, - legacySessionID?: string, + runtime: { id: () => string | undefined; ensure: () => Promise }, ) { - const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] - - const initialRootId = uuid() - const [store, setStore, _, ready] = persisted( - { - ...terminalPersistTarget(scope, dir, legacy), - migrate: migrateTerminalState, - }, - createStore({ - all: [], - focusedPaneId: initialRootId, - root: { kind: "leaf", id: initialRootId, activeId: undefined, ptys: [] }, - }), - ) - - // ── tree read helpers over the reactive store ── - const focusedLeaf = (): PaneLeaf => { - return findLeafById(store.root, store.focusedPaneId) ?? getLeaves(store.root)[0]! + const rootId = uuid() + const [store, setStore] = createStore({ all: [] as LocalPTY[] }) + const [root, setRootSignal] = createSignal({ + kind: "leaf", + id: rootId, + activeId: undefined, + ptys: [], + }) + const [focusedPaneId, setFocusedPaneId] = createSignal(rootId) + const [paneBounds, setPaneBounds] = createSignal>({}) + const [pendingVersion, setPendingVersion] = createSignal(0) + const [createError, setCreateError] = createSignal() + const [closeRequest, setCloseRequest] = createSignal(0) + const pendingCreates = new Set() + const pendingRetries = new Set() + const pendingSplitLeaves = new Set() + const pendingTitleNumbers = new Set() + let generation = 0 + + const touchPending = () => setPendingVersion((value) => value + 1) + const focusedLeaf = () => findLeafById(root(), focusedPaneId()) ?? edgeLeaf(root(), 0) + const setRoot = (next: PaneNode | ((root: PaneNode) => PaneNode)) => { + const value = typeof next === "function" ? next(root()) : next + setRootSignal(clonePaneTree(value)) } - - /** - * Commit a rebuilt tree via reconcile keyed by `id`. Our pure tree helpers - * return brand-new object graphs; a plain setStore("root", next) would swap - * every node's proxy and remount all components (dropping their - * WebSockets) on each edit — fatal for resize drags that fire per frame. - * Reconciling by id keeps identity for unchanged subtrees so only the fields - * that actually changed (a leaf's activeId, a split's sizes) update in place. - */ - const setRoot = (next: PaneNode | ((prev: PaneNode) => PaneNode)) => { - const value = typeof next === "function" ? next(store.root) : next - // Clone to plain objects first: helpers may embed a live store proxy in the - // returned tree, and reconciling a proxy back into the store forms a cycle. - setStore("root", reconcile(clonePaneTree(value), { key: "id", merge: false })) + const commitRoot = (next: PaneNode, focused?: PaneId) => { + const leaves = getLeaves(next) + const nextFocused = + focused && leaves.some((leaf) => leaf.id === focused) + ? focused + : leaves.some((leaf) => leaf.id === focusedPaneId()) + ? focusedPaneId() + : edgeLeaf(next, 0).id + batch(() => { + setRoot(next) + setFocusedPaneId(nextFocused) + }) } - - /** Commit a new root, keeping focusedPaneId pointing at a real leaf. */ - const commitRoot = (nextRoot: PaneNode, nextFocused?: PaneId) => { - const leaves = getLeaves(nextRoot) - const focused = - nextFocused && leaves.some((l) => l.id === nextFocused) - ? nextFocused - : leaves.some((l) => l.id === store.focusedPaneId) - ? store.focusedPaneId - : leaves[0]!.id + const reset = () => { + generation += 1 + pendingCreates.clear() + pendingRetries.clear() + pendingSplitLeaves.clear() + pendingTitleNumbers.clear() + setCreateError(undefined) + setPaneBounds({}) + touchPending() + const id = uuid() batch(() => { - setRoot(nextRoot) - setStore("focusedPaneId", focused) + setRoot({ kind: "leaf", id, activeId: undefined, ptys: [] }) + setFocusedPaneId(id) + setStore("all", []) }) } - - const pickNextTerminalNumber = () => { - const existingTitleNumbers = new Set( + const pickTitleNumber = () => { + const used = new Set( store.all.flatMap((pty) => { - const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined - if (direct !== undefined) return [direct] - const parsed = numberFromTitle(pty.title) - if (parsed === undefined) return [] - return [parsed] + if (Number.isFinite(pty.titleNumber) && pty.titleNumber > 0) return [pty.titleNumber] + const parsed = titleNumber(pty.title, MAX_TERMINAL_SESSIONS) + return parsed === undefined ? [] : [parsed] }), ) - + for (const number of pendingTitleNumbers) used.add(number) + return Array.from({ length: used.size + 1 }, (_, index) => index + 1).find((number) => !used.has(number)) ?? 1 + } + const discard = async (ptyId: string) => { + const result = await sdk.client.pty.remove({ ptyID: ptyId }, { throwOnError: false }) + if (result.response?.ok || result.response?.status === 404) return + logFailure( + terminalFailure({ + operation: "close", + error: result.error, + status: result.response?.status, + ptyId, + directory: sdk.directory, + runtimeId: runtime.id(), + }), + ) + } + const canSplitLeaf = (paneId: PaneId, direction: PaneSplit["dir"], allowPending = false) => { + pendingVersion() + const leaf = findLeafById(root(), paneId) + const bounds = paneBounds()[paneId] + const available = direction === "horizontal" ? bounds?.width : bounds?.height + const minimum = direction === "horizontal" ? MIN_TERMINAL_PANE_WIDTH : MIN_TERMINAL_PANE_HEIGHT return ( - Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( - (number) => !existingTitleNumbers.has(number), - ) ?? 1 + Boolean(leaf?.activeId && leaf.ptys.includes(leaf.activeId)) && + (leaf ? (leafLevel(root(), leaf.id) ?? MAX_SPLIT_DEPTH) : MAX_SPLIT_DEPTH) < MAX_SPLIT_DEPTH && + available !== undefined && + available >= minimum * 2 && + getLeaves(root()).length + pendingSplitLeaves.size <= + (allowPending ? MAX_TERMINAL_PANES : MAX_TERMINAL_PANES - 1) && + (allowPending || !pendingSplitLeaves.has(paneId)) ) } - - const removeExited = (id: string) => { - const index = store.all.findIndex((x) => x.id === id) - if (index === -1) return + const createPty = async (place: (pty: LocalPTY) => void, canPlace: () => boolean = () => true) => { + if (store.all.length + pendingCreates.size >= MAX_TERMINAL_SESSIONS || !canPlace()) return false + const requestId = uuid() + const number = pickTitleNumber() + pendingCreates.add(requestId) + pendingTitleNumbers.add(number) + setCreateError(undefined) + touchPending() + if (!runtime.id()) void runtime.ensure().catch(() => undefined) + const epoch = generation + try { + const result = await withServerAbortRetry(() => sdk.client.pty.create({ title: defaultTitle(number) })) + const ptyId = result.data?.id + if (!ptyId) throw new Error("Terminal creation returned no PTY id") + if (epoch !== generation) return false + if (!canPlace()) { + await discard(ptyId) + return false + } + place({ + id: uuid(), + ptyId, + title: result.data?.title ?? defaultTitle(number), + titleNumber: number, + status: "connecting", + }) + return true + } catch (error) { + if (epoch !== generation) return false + const failure = terminalFailure({ + operation: "create", + error, + directory: sdk.directory, + runtimeId: runtime.id(), + }) + setCreateError(failure) + logFailure(failure) + void notifyCreateFailed(failure) + return false + } finally { + pendingCreates.delete(requestId) + pendingTitleNumbers.delete(number) + touchPending() + } + } + const removeLocal = (id: string) => { + if (!store.all.some((pty) => pty.id === id)) return batch(() => { - setRoot((root) => removePtyFromTree(root, id)) + const next = removePtyFromTree(root(), id) + const leaves = getLeaves(next) + setRoot(next) setStore( "all", - produce((draft) => { - draft.splice(index, 1) - }), + store.all.filter((pty) => pty.id !== id), ) - // focusedPaneId may now point at a collapsed leaf; repair it. - const leaves = getLeaves(store.root) - if (!leaves.some((l) => l.id === store.focusedPaneId)) { - setStore("focusedPaneId", leaves[0]!.id) - } + if (!leaves.some((leaf) => leaf.id === focusedPaneId())) setFocusedPaneId(edgeLeaf(next, 0).id) }) } - - const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { - removeExited(event.properties.id) - }) - onCleanup(unsub) - - const update = (client: ReturnType["client"], pty: Partial & { id: string }) => { - const index = store.all.findIndex((x) => x.id === pty.id) - const previous = index >= 0 ? store.all[index] : undefined - if (index >= 0) { - setStore("all", index, (item) => ({ ...item, ...pty })) - } - client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - .catch((error: unknown) => { - // Benign races that must NOT surface as a scary error (or, in dev, kick - // the user out of their flow): - // - "PTY session not found": a resize/title update racing with the PTY - // exiting server-side. `pty.exited` + removeExited already retire it. - // - HTTP 503 with empty body: the instance scope is tearing down during - // a project switch / reload (see handlers/pty.ts). The terminal will - // re-establish on the new instance; this is expected, not an error. - const message = error instanceof Error ? error.message : String(error) - const benign = /PTY session not found/.test(message) || /\b503\b/.test(message) - if (benign) { - if (previous) { - const currentIndex = store.all.findIndex((item) => item.id === pty.id) - if (currentIndex >= 0) setStore("all", currentIndex, previous) - } - return - } - if (previous) { - const currentIndex = store.all.findIndex((item) => item.id === pty.id) - if (currentIndex >= 0) setStore("all", currentIndex, previous) - } - console.error("Failed to update terminal", error) - }) - } - - const clone = async (client: ReturnType["client"], id: string) => { - const index = store.all.findIndex((x) => x.id === id) - const pty = store.all[index] - if (!pty) return - const next = await withServerAbortRetry(() => client.pty.create({ title: pty.title })).catch( - (error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }, - ) - if (!next?.data) return - - batch(() => { - setStore("all", index, { - id: next.data.id, - title: next.data.title ?? pty.title, - titleNumber: pty.titleNumber, - buffer: undefined, - cursor: undefined, - scrollY: undefined, - rows: undefined, - cols: undefined, - }) - setRoot((root) => replacePtyIdInTree(root, id, next.data.id)) + const markServerExit = (ptyId: string) => { + const index = store.all.findIndex((pty) => pty.ptyId === ptyId) + if (index === -1) return + setStore("all", index, { + status: "exited", + error: terminalFailure({ + operation: "connect", + error: new Error("Terminal process exited"), + status: 404, + ptyId, + directory: sdk.directory, + runtimeId: runtime.id(), + }), }) } - /** Create a PTY on the server and hand its id to `place`, which mutates the tree. */ - const createPty = (place: (id: string, title: string, titleNumber: number) => void) => { - if (store.all.length >= MAX_TERMINAL_SESSIONS) return - const nextNumber = pickNextTerminalNumber() - withServerAbortRetry(() => sdk.client.pty.create({ title: defaultTitle(nextNumber) })) - .then((pty: { data?: { id?: string; title?: string } }) => { - const id = pty.data?.id - if (!id) return - const title = pty.data?.title ?? defaultTitle(nextNumber) - batch(() => { - setStore("all", store.all.length, { id, title, titleNumber: nextNumber }) - place(id, title, nextNumber) - }) - }) - .catch((error: unknown) => { - console.error("Failed to create terminal", error) - void notifyTerminalCreateFailed(error) - }) - } + const unsubExited = sdk.event.on("pty.exited", (event) => markServerExit(event.properties.id)) + const unsubDeleted = sdk.event.on("pty.deleted", (event) => markServerExit(event.properties.id)) + onCleanup(() => { + unsubExited() + unsubDeleted() + }) return { - ready, + ready: () => true, all: createMemo(() => store.all), - root: createMemo(() => store.root), - focusedPaneId: createMemo(() => store.focusedPaneId), + root, + focusedPaneId, active: createMemo(() => focusedLeaf().activeId), - paneLevel(paneId: PaneId) { - return leafLevel(store.root, paneId) ?? 1 + creating: () => { + pendingVersion() + return pendingCreates.size > 0 + }, + createError, + closeRequest, + runtimeId: runtime.id, + paneBounds, + paneLevel: (paneId: PaneId) => leafLevel(root(), paneId) ?? 1, + leafPtys: (paneId: PaneId) => findLeafById(root(), paneId)?.ptys ?? [], + setPaneBounds(paneId: PaneId, bounds: PaneBounds | undefined) { + setPaneBounds((current) => { + if (bounds) return { ...current, [paneId]: bounds } + const next = { ...current } + delete next[paneId] + return next + }) }, - canSplit(paneId: PaneId) { - return (leafLevel(store.root, paneId) ?? 1) < MAX_SPLIT_DEPTH + canSplit(paneId: PaneId, direction: PaneSplit["dir"] = "horizontal") { + return canSplitLeaf(paneId, direction) }, - leafPtys(paneId: PaneId) { - return findLeafById(store.root, paneId)?.ptys ?? [] + resetRuntime() { + reset() }, clear() { - batch(() => { - const rootId = uuid() - setRoot({ kind: "leaf", id: rootId, activeId: undefined, ptys: [] }) - setStore("focusedPaneId", rootId) - setStore("all", []) - }) + const ptyIds = store.all.map((pty) => pty.ptyId) + reset() + for (const ptyId of ptyIds) void discard(ptyId) }, - new() { - const leafId = focusedLeaf().id - createPty((id) => { - setRoot((root) => { - const leaf = findLeafById(root, leafId) - if (!leaf) return root - return updateLeaf(root, leafId, { ptys: [...leaf.ptys, id], activeId: id }) - }) - }) + async new() { + const paneId = focusedLeaf().id + return createPty( + (pty) => { + batch(() => { + setStore("all", store.all.length, pty) + setRoot((root) => { + const leaf = findLeafById(root, paneId) + if (!leaf) return root + return updateLeaf(root, paneId, { ptys: [...leaf.ptys, pty.id], activeId: pty.id }) + }) + }) + }, + () => Boolean(findLeafById(root(), paneId)), + ) }, - split(dir: "horizontal" | "vertical", paneId?: PaneId) { - const leaf = (paneId ? findLeafById(store.root, paneId) : undefined) ?? focusedLeaf() - const level = leafLevel(store.root, leaf.id) ?? 1 - if (level >= MAX_SPLIT_DEPTH) return + async split(direction: PaneSplit["dir"], paneId?: PaneId) { + const leaf = (paneId ? findLeafById(root(), paneId) : undefined) ?? focusedLeaf() + if (!canSplitLeaf(leaf.id, direction)) return false + const targetId = leaf.id const newLeafId = uuid() - createPty((id) => { - const newLeaf: PaneLeaf = { kind: "leaf", id: newLeafId, activeId: id, ptys: [id] } - setRoot((root) => splitLeaf(root, leaf.id, dir, newLeaf)) - setStore("focusedPaneId", newLeafId) + pendingSplitLeaves.add(targetId) + touchPending() + return createPty( + (pty) => { + batch(() => { + setStore("all", store.all.length, pty) + setRoot((root) => + balanceSplits( + splitLeaf(root, targetId, direction, { + kind: "leaf", + id: newLeafId, + activeId: pty.id, + ptys: [pty.id], + }), + direction, + ), + ) + setFocusedPaneId(newLeafId) + }) + }, + () => canSplitLeaf(targetId, direction, true), + ).finally(() => { + pendingSplitLeaves.delete(targetId) + touchPending() }) }, - update(pty: Partial & { id: string }) { - update(sdk.client, pty) + async retry(id: string) { + if (pendingRetries.has(id)) return false + const current = store.all.find((pty) => pty.id === id) + if (!current) return false + pendingRetries.add(id) + setStore("all", store.all.indexOf(current), { status: "connecting", error: undefined }) + if (!runtime.id()) void runtime.ensure().catch(() => undefined) + const epoch = generation + try { + const result = await withServerAbortRetry(() => sdk.client.pty.create({ title: current.title })) + const ptyId = result.data?.id + if (!ptyId) throw new Error("Terminal creation returned no PTY id") + const index = store.all.findIndex((pty) => pty.id === id) + if (epoch !== generation || index === -1) { + if (epoch === generation) await discard(ptyId) + return false + } + const oldPtyId = store.all[index].ptyId + setStore("all", index, { + ptyId, + title: result.data?.title ?? current.title, + status: "connecting", + error: undefined, + }) + if (oldPtyId !== ptyId) void discard(oldPtyId) + return true + } catch (error) { + const index = store.all.findIndex((pty) => pty.id === id) + if (epoch !== generation || index === -1) return false + const failure = terminalFailure({ + operation: "create", + error, + ptyId: store.all[index].ptyId, + directory: sdk.directory, + runtimeId: runtime.id(), + }) + setStore("all", index, { status: "error", error: failure }) + logFailure(failure) + return false + } finally { + pendingRetries.delete(id) + } }, - trim(id: string) { - const index = store.all.findIndex((x) => x.id === id) + setStatus(id: string, ptyId: string, status: TerminalStatus, error?: TerminalFailure) { + const index = store.all.findIndex((pty) => pty.id === id && pty.ptyId === ptyId) if (index === -1) return - setStore("all", index, (pty) => trimTerminal(pty)) + setStore("all", index, { status, error: status === "ready" ? undefined : error }) }, - trimAll() { - setStore("all", (all) => { - const next = all.map(trimTerminal) - if (next.every((pty, index) => pty === all[index])) return all - return next + update(input: Partial & { id: string }) { + if (input.title === undefined) return + const index = store.all.findIndex((pty) => pty.id === input.id) + const pty = store.all[index] + if (!pty || input.title === pty.title) return + const previous = pty.title + const ptyId = pty.ptyId + setStore("all", index, "title", input.title) + void sdk.client.pty.update({ ptyID: ptyId, title: input.title }, { throwOnError: false }).then((result) => { + if (result.response?.ok) return + const failure = terminalFailure({ + operation: "rename", + error: result.error, + status: result.response?.status, + ptyId, + directory: sdk.directory, + runtimeId: runtime.id(), + }) + const currentIndex = store.all.findIndex((item) => item.id === input.id && item.ptyId === ptyId) + if (currentIndex === -1) return + if (failure.status === 404) { + setStore("all", currentIndex, { status: "exited", error: failure }) + } else if (store.all[currentIndex].title === input.title) { + setStore("all", currentIndex, "title", previous) + } + logFailure(failure) }) }, - async clone(id: string) { - await clone(sdk.client, id) - }, - bind() { - const client = sdk.client - return { - trim(id: string) { - const index = store.all.findIndex((x) => x.id === id) - if (index === -1) return - setStore("all", index, (pty) => trimTerminal(pty)) - }, - update(pty: Partial & { id: string }) { - update(client, pty) - }, - async clone(id: string) { - await clone(client, id) - }, - } - }, open(id: string) { - const leaf = findLeafForPty(store.root, id) - if (!leaf) return - commitRoot(updateLeaf(store.root, leaf.id, { activeId: id }), leaf.id) + const leaf = findLeafForPty(root(), id) + if (leaf) commitRoot(updateLeaf(root(), leaf.id, { activeId: id }), leaf.id) }, - activateInPane(paneId: PaneId, ptyId: string) { - const leaf = findLeafById(store.root, paneId) - if (!leaf || !leaf.ptys.includes(ptyId)) return - commitRoot(updateLeaf(store.root, paneId, { activeId: ptyId }), paneId) + activateInPane(paneId: PaneId, id: string) { + const leaf = findLeafById(root(), paneId) + if (leaf?.ptys.includes(id)) commitRoot(updateLeaf(root(), paneId, { activeId: id }), paneId) }, setFocusedPane(paneId: PaneId) { - if (findLeafById(store.root, paneId)) setStore("focusedPaneId", paneId) + if (findLeafById(root(), paneId)) setFocusedPaneId(paneId) }, - movePtyToPane(ptyId: string, targetPaneId: PaneId) { - commitRoot(movePtyBetweenLeaves(store.root, ptyId, targetPaneId), targetPaneId) + movePtyToPane(id: string, targetPaneId: PaneId) { + commitRoot(movePtyBetweenLeaves(root(), id, targetPaneId), targetPaneId) }, resizePane(splitId: PaneId, sizes: readonly [number, number]) { setRoot((root) => updateSplitSizes(root, splitId, sizes)) }, focusNeighbor(dir: "left" | "right" | "up" | "down") { - const next = neighborLeafId(store.root, store.focusedPaneId, dir) - if (next) setStore("focusedPaneId", next) + const next = neighborLeafId(root(), focusedPaneId(), dir) + if (next) setFocusedPaneId(next) }, next() { const leaf = focusedLeaf() + if (!leaf.ptys.length) return const index = leaf.ptys.indexOf(leaf.activeId ?? "") - if (leaf.ptys.length === 0) return - const nextIndex = index === -1 ? 0 : (index + 1) % leaf.ptys.length - commitRoot(updateLeaf(store.root, leaf.id, { activeId: leaf.ptys[nextIndex] }), leaf.id) + commitRoot( + updateLeaf(root(), leaf.id, { activeId: leaf.ptys[index === -1 ? 0 : (index + 1) % leaf.ptys.length] }), + leaf.id, + ) }, previous() { const leaf = focusedLeaf() - if (leaf.ptys.length === 0) return + if (!leaf.ptys.length) return const index = leaf.ptys.indexOf(leaf.activeId ?? "") - const prevIndex = index <= 0 ? leaf.ptys.length - 1 : index - 1 - commitRoot(updateLeaf(store.root, leaf.id, { activeId: leaf.ptys[prevIndex] }), leaf.id) + commitRoot( + updateLeaf(root(), leaf.id, { activeId: leaf.ptys[index <= 0 ? leaf.ptys.length - 1 : index - 1] }), + leaf.id, + ) }, async close(id: string) { - const index = store.all.findIndex((f) => f.id === id) - if (index !== -1) { - batch(() => { - setRoot((root) => removePtyFromTree(root, id)) - setStore( - "all", - produce((all) => { - all.splice(index, 1) - }), - ) - const leaves = getLeaves(store.root) - if (!leaves.some((l) => l.id === store.focusedPaneId)) { - setStore("focusedPaneId", leaves[0]!.id) - } - }) - } - - await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { - console.error("Failed to close terminal", error) - }) + const closePanel = store.all.length === 1 && store.all[0]?.id === id + const ptyId = store.all.find((pty) => pty.id === id)?.ptyId + removeLocal(id) + if (closePanel) setCloseRequest((value) => value + 1) + if (ptyId) await discard(ptyId) }, async closePane(paneId: PaneId) { - const leaf = findLeafById(store.root, paneId) + const leaf = findLeafById(root(), paneId) if (!leaf) return const ids = [...leaf.ptys] - batch(() => { - setRoot((root) => { - let next = root - for (const id of ids) next = removePtyFromTree(next, id) - return next - }) - setStore( - "all", - produce((all) => { - for (const id of ids) { - const idx = all.findIndex((p) => p.id === id) - if (idx !== -1) all.splice(idx, 1) - } - }), - ) - const leaves = getLeaves(store.root) - if (!leaves.some((l) => l.id === store.focusedPaneId)) { - setStore("focusedPaneId", leaves[0]!.id) - } - }) - for (const id of ids) { - await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { - console.error("Failed to close terminal", error) - }) - } + const closePanel = ids.length > 0 && ids.length === store.all.length + const ptyIds = store.all.filter((pty) => ids.includes(pty.id)).map((pty) => pty.ptyId) + for (const id of ids) removeLocal(id) + if (closePanel) setCloseRequest((value) => value + 1) + await Promise.all(ptyIds.map(discard)) }, move(id: string, to: number) { setRoot((root) => reorderPtyInLeaf(root, id, to)) @@ -888,100 +725,61 @@ function createWorkspaceTerminalSession( } } +/** @internal */ export const TerminalTesting = { createWorkspaceTerminalSession } + export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ name: "Terminal", gate: false, init: () => { const sdk = useSDK() const server = useServer() - const params = useParams() - const cache = new Map() + const platform = usePlatform() const scope = server.scope() - - caches.add(cache) - onCleanup(() => caches.delete(cache)) - - const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } - cache.clear() - } - - onCleanup(disposeAll) - - const prune = () => { - while (cache.size > MAX_TERMINAL_SESSIONS) { - const first = cache.keys().next().value - if (!first) return - const entry = cache.get(first) - entry?.dispose() - cache.delete(first) - } - } - - const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => { - // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. - const key = getWorkspaceTerminalCacheKey(dir, serverScope) - const existing = cache.get(key) - if (existing) { - cache.delete(key) - cache.set(key, existing) - return existing.value - } - - const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID), - dispose, - })) - - cache.set(key, entry) - prune() - return entry.value + let runtimeId: string | undefined + let runtimeRequest: Promise | undefined + let terminal: TerminalSession | undefined + + const ensureRuntime = () => { + if (runtimeRequest) return runtimeRequest + runtimeRequest = sdk.client.global + .health({ cache: "no-store" }) + .then((result) => { + const next = result.data?.runtimeId ?? sdk.url + if (!runtimeId) { + runtimeId = next + return + } + if (runtimeId === next) return + console.info("[terminal] server runtime changed", { previousRuntimeId: runtimeId, runtimeId: next }) + runtimeId = next + terminal?.resetRuntime() + }) + .catch((error) => { + if (import.meta.env.DEV) console.debug("[terminal] runtime check failed", error) + }) + .finally(() => { + runtimeRequest = undefined + }) + return runtimeRequest } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) - - createEffect( - on( - () => ({ dir: params.dir, id: params.id, scope }), - (next, prev) => { - if (!prev?.dir) return - if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return - if (next.dir === prev.dir && next.id && next.scope === prev.scope) return - loadWorkspace(prev.dir, prev.id, prev.scope).trimAll() - }, - { defer: true }, - ), - ) + terminal = createWorkspaceTerminalSession(sdk, { id: () => runtimeId, ensure: ensureRuntime }) + const registered = { dir: sdk.directory, scope, value: terminal } + sessions.add(registered) + removeTerminalPersistence(sdk.directory, undefined, platform, scope) + + onMount(() => { + void ensureRuntime() + const timer = setInterval(() => { + if (terminal?.all().length) void ensureRuntime() + }, RUNTIME_POLL_MS) + onCleanup(() => clearInterval(timer)) + }) + onCleanup(() => { + sessions.delete(registered) + terminal?.clear() + }) - return { - ready: () => workspace().ready(), - all: () => workspace().all(), - root: () => workspace().root(), - focusedPaneId: () => workspace().focusedPaneId(), - paneLevel: (paneId: PaneId) => workspace().paneLevel(paneId), - canSplit: (paneId: PaneId) => workspace().canSplit(paneId), - leafPtys: (paneId: PaneId) => workspace().leafPtys(paneId), - active: () => workspace().active(), - new: () => workspace().new(), - split: (dir: "horizontal" | "vertical", paneId?: PaneId) => workspace().split(dir, paneId), - update: (pty: Partial & { id: string }) => workspace().update(pty), - trim: (id: string) => workspace().trim(id), - trimAll: () => workspace().trimAll(), - clone: (id: string) => workspace().clone(id), - bind: () => workspace(), - open: (id: string) => workspace().open(id), - activateInPane: (paneId: PaneId, ptyId: string) => workspace().activateInPane(paneId, ptyId), - setFocusedPane: (paneId: PaneId) => workspace().setFocusedPane(paneId), - movePtyToPane: (ptyId: string, targetPaneId: PaneId) => workspace().movePtyToPane(ptyId, targetPaneId), - resizePane: (splitId: PaneId, sizes: readonly [number, number]) => workspace().resizePane(splitId, sizes), - focusNeighbor: (dir: "left" | "right" | "up" | "down") => workspace().focusNeighbor(dir), - close: (id: string) => workspace().close(id), - closePane: (paneId: PaneId) => workspace().closePane(paneId), - move: (id: string, to: number) => workspace().move(id, to), - next: () => workspace().next(), - previous: () => workspace().previous(), - } + return terminal }, }) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 181b543c..807a7b6b 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -553,12 +553,6 @@ export const dict = { "settings.general.section.updates": "التحديثات", "settings.general.section.sounds": "المؤثرات الصوتية", "settings.general.section.feed": "الخلاصة", - "settings.general.section.sharing": "المشاركة", - "settings.general.row.shareUrl.title": "عنوان URL لخادم المشاركة", - "settings.general.row.shareUrl.description": "عنوان URL الأساسي للخادم المستخدم عند مشاركة الجلسات. اتركه فارغًا لاستخدام الافتراضي.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "تفعيل لجنة الخبراء افتراضيًا", - "settings.general.row.expertPanelDefault.description": "بدء المحادثات الجديدة مع تفعيل لجنة الخبراء، بحيث يبدأ زرها مراجعة السياق الحالي عند الطلب.", "settings.general.section.display": "شاشة العرض", "settings.general.row.language.title": "اللغة", "settings.general.row.language.description": "تغيير لغة العرض لـ DeepAgent Code", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index b2ff7bef..db2de284 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -560,12 +560,6 @@ export const dict = { "settings.general.section.updates": "Atualizações", "settings.general.section.sounds": "Efeitos sonoros", "settings.general.section.feed": "Feed", - "settings.general.section.sharing": "Compartilhamento", - "settings.general.row.shareUrl.title": "URL do servidor de compartilhamento", - "settings.general.row.shareUrl.description": "URL base do servidor usado ao compartilhar sessões. Deixe em branco para usar o padrão.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Ativar painel de especialistas por padrão", - "settings.general.row.expertPanelDefault.description": "Iniciar novas conversas com o painel de especialistas ativado, para que seu botão inicie uma revisão do contexto atual sob demanda.", "settings.general.section.display": "Tela", "settings.general.row.language.title": "Idioma", "settings.general.row.language.description": "Alterar o idioma de exibição do DeepAgent Code", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index cc9f9115..7190b688 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -625,12 +625,6 @@ export const dict = { "settings.general.section.updates": "Ažuriranja", "settings.general.section.sounds": "Zvučni efekti", "settings.general.section.feed": "Feed", - "settings.general.section.sharing": "Dijeljenje", - "settings.general.row.shareUrl.title": "URL servera za dijeljenje", - "settings.general.row.shareUrl.description": "Osnovni URL servera koji se koristi pri dijeljenju sesija. Ostavite prazno za zadano.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Zadano uključi ekspertni panel", - "settings.general.row.expertPanelDefault.description": "Pokreni nove razgovore s uključenim ekspertnim panelom, tako da njegovo dugme po potrebi pokreće pregled trenutnog konteksta.", "settings.general.section.display": "Prikaz", "settings.general.row.language.title": "Jezik", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 6af26897..fe57ab9f 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -620,12 +620,6 @@ export const dict = { "settings.general.section.updates": "Opdateringer", "settings.general.section.sounds": "Lydeffekter", "settings.general.section.feed": "Feed", - "settings.general.section.sharing": "Deling", - "settings.general.row.shareUrl.title": "Delings-server-URL", - "settings.general.row.shareUrl.description": "Basis-URL for serveren, der bruges ved deling af sessioner. Lad stå tomt for standard.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Aktivér ekspertpanel som standard", - "settings.general.row.expertPanelDefault.description": "Start nye samtaler med ekspertpanelet aktiveret, så knappen kan gennemgå den aktuelle kontekst efter behov.", "settings.general.section.display": "Skærm", "settings.general.row.language.title": "Sprog", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e682c164..ae2da2b4 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -569,12 +569,6 @@ export const dict = { "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Soundeffekte", "settings.general.section.feed": "Feed", - "settings.general.section.sharing": "Teilen", - "settings.general.row.shareUrl.title": "Server-URL für Freigabe", - "settings.general.row.shareUrl.description": "Basis-URL des Servers, der beim Teilen von Sitzungen verwendet wird. Leer lassen für Standard.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Expertengremium standardmäßig aktivieren", - "settings.general.row.expertPanelDefault.description": "Neue Unterhaltungen mit aktiviertem Expertengremium starten, damit dessen Schaltfläche den aktuellen Kontext bei Bedarf prüft.", "settings.general.section.display": "Anzeige", "settings.general.row.language.title": "Sprache", "settings.general.row.language.description": "Die Anzeigesprache für DeepAgent Code ändern", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ebea8dba..430fd0dd 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -55,6 +55,9 @@ export const dict = { "command.input.focus": "Focus input", "session.sidePanel.toggle": "Toggle right sidebar", "command.terminal.toggle": "Toggle terminal", + "command.panel.toggle": "Toggle bottom panel", + "command.debugConsole.reveal": "Show debug console", + "command.problems.reveal": "Show problems", "command.fileTree.toggle": "Toggle file tree", "command.review.toggle": "Toggle review", "command.terminal.new": "New terminal", @@ -120,11 +123,17 @@ export const dict = { "dialog.provider.deepagent-code.tagline": "DeepAgent Code managed model gateway", "dialog.provider.deepseek.note": "DeepSeek models via API key", "dialog.provider.zhipuai.note": "GLM models from Zhipu AI via API key", + "dialog.provider.zhipuai-coding-plan.note": "GLM Coding Plan subscription from Zhipu AI via API key", + "dialog.provider.zai.note": "GLM models from Z.AI via API key", + "dialog.provider.zai-coding-plan.note": "GLM Coding Plan subscription from Z.AI via API key", + "dialog.provider.kimi-for-coding.note": "Kimi Code (Kimi for Coding) subscription via API key", + "dialog.provider.moonshotai-cn.note": "Kimi models from Moonshot AI open platform via API key", "dialog.provider.deepagentCodeGo.tagline": "Low cost subscription for everyone", "dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max", "dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot", "dialog.provider.openai.note": "GPT models for fast, capable general AI tasks", "dialog.provider.google.note": "Gemini models for fast, structured responses", + "dialog.provider.xai.note": "Grok models from xAI via API key", "dialog.provider.openrouter.note": "Access all supported models from one provider", "dialog.provider.vercel.note": "Unified access to AI models with smart routing", @@ -802,6 +811,24 @@ export const dict = { "session.panel.oversight": "Oversight", "session.panel.debug": "Debug", "session.panel.profile": "Profiler", + "session.panel.terminal": "Terminal", + "session.panel.debugConsole": "Debug Console", + "session.panel.problems": "Problems", + "session.panel.bottom": "Bottom Panel", + "session.panel.resize": "Resize bottom panel", + "session.panel.moveToSide": "Move to Right Sidebar", + "session.panel.emptyBottom": "No views are currently in the Bottom Panel.", + "session.panel.noBottomViews": "Move a Panel View to the Bottom Panel first.", + "session.panel.views": "Panel Views", + "session.panel.location.bottom": "Bottom Panel", + "session.panel.location.side": "Right Sidebar", + "session.panel.showView": "Show View", + "session.panel.moveToBottom": "Move to Bottom Panel", + "session.panel.resetLocation": "Reset to Bottom Panel", + "problems.error": "errors", + "problems.warning": "warnings", + "problems.empty": "No known workspace diagnostics.", + "problems.errorLoading": "Unable to load workspace diagnostics.", "oversight.common.refresh": "Refresh", "oversight.dashboard.title": "Agent Dashboard", "oversight.dashboard.loading": "Loading metrics…", @@ -969,10 +996,17 @@ export const dict = { "terminal.title": "Terminal", "terminal.title.numbered": "Terminal {{number}}", "terminal.close": "Close terminal", + "terminal.retry": "Retry", "terminal.split": "Split terminal", + "terminal.status.starting": "Starting terminal...", + "terminal.status.connecting": "Connecting to terminal...", + "terminal.status.reconnecting": "Reconnecting to terminal...", + "terminal.status.exited": "Terminal process exited.", "terminal.pane.close": "Close pane", "terminal.dock.terminal": "Terminal", "terminal.dock.debugConsole": "Debug Console", + "dock.moveToSide": "Move to side panel", + "dock.moveToBottom": "Move to bottom dock", "terminal.debugConsole.placeholder": "Debug console output will appear here.", "terminal.connectionLost.title": "Connection Lost", "terminal.connectionLost.abnormalClose": "WebSocket closed abnormally: {{code}}", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5ec07a63..0d7123d7 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -628,12 +628,6 @@ export const dict = { "settings.general.section.updates": "Actualizaciones", "settings.general.section.sounds": "Efectos de sonido", "settings.general.section.feed": "Feed", - "settings.general.section.sharing": "Compartir", - "settings.general.row.shareUrl.title": "URL del servidor para compartir", - "settings.general.row.shareUrl.description": "URL base del servidor usado al compartir sesiones. Déjalo en blanco para usar el predeterminado.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Activar el panel de expertos por defecto", - "settings.general.row.expertPanelDefault.description": "Iniciar nuevas conversaciones con el panel de expertos activado, para que su botón revise el contexto actual cuando se solicite.", "settings.general.section.display": "Pantalla", "settings.general.row.language.title": "Idioma", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 9e01f181..bea1dcc1 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -567,12 +567,6 @@ export const dict = { "settings.general.section.updates": "Mises à jour", "settings.general.section.sounds": "Effets sonores", "settings.general.section.feed": "Flux", - "settings.general.section.sharing": "Partage", - "settings.general.row.shareUrl.title": "URL du serveur de partage", - "settings.general.row.shareUrl.description": "URL de base du serveur utilisé pour partager les sessions. Laissez vide pour la valeur par défaut.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Activer le panel d'experts par défaut", - "settings.general.row.expertPanelDefault.description": "Démarrer les nouvelles conversations avec le panel d'experts activé, afin que son bouton examine le contexte actuel à la demande.", "settings.general.section.display": "Affichage", "settings.general.row.language.title": "Langue", "settings.general.row.language.description": "Changer la langue d'affichage pour DeepAgent Code", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ad5e45cb..3fbb6ed1 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -559,12 +559,6 @@ export const dict = { "settings.general.section.updates": "アップデート", "settings.general.section.sounds": "効果音", "settings.general.section.feed": "フィード", - "settings.general.section.sharing": "共有", - "settings.general.row.shareUrl.title": "共有サーバー URL", - "settings.general.row.shareUrl.description": "セッション共有時に使用するサーバーのベース URL。空欄でデフォルトを使用します。", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "エキスパートパネルをデフォルトで有効化", - "settings.general.row.expertPanelDefault.description": "新しい会話をエキスパートパネル有効の状態で開始し、ボタンで現在のコンテキストをいつでも会議レビューできるようにします。", "settings.general.section.display": "ディスプレイ", "settings.general.row.language.title": "言語", "settings.general.row.language.description": "DeepAgent Codeの表示言語を変更します", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index d8f0e408..0f1d882f 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -555,12 +555,6 @@ export const dict = { "settings.general.section.updates": "업데이트", "settings.general.section.sounds": "효과음", "settings.general.section.feed": "피드", - "settings.general.section.sharing": "공유", - "settings.general.row.shareUrl.title": "공유 서버 URL", - "settings.general.row.shareUrl.description": "세션 공유 시 사용하는 서버의 기본 URL입니다. 비워 두면 기본값을 사용합니다.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "전문가 패널을 기본으로 활성화", - "settings.general.row.expertPanelDefault.description": "새 대화를 전문가 패널이 활성화된 상태로 시작하여 버튼으로 현재 컨텍스트를 필요할 때 검토할 수 있게 합니다.", "settings.general.section.display": "디스플레이", "settings.general.row.language.title": "언어", "settings.general.row.language.description": "DeepAgent Code 표시 언어 변경", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 2d21e9a8..68ec2d5c 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -628,12 +628,6 @@ export const dict = { "settings.general.section.updates": "Oppdateringer", "settings.general.section.sounds": "Lydeffekter", "settings.general.section.feed": "Feed", - "settings.general.section.sharing": "Deling", - "settings.general.row.shareUrl.title": "Delingsserver-URL", - "settings.general.row.shareUrl.description": "Basis-URL for serveren som brukes ved deling av økter. La stå tomt for standard.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Aktiver ekspertpanel som standard", - "settings.general.row.expertPanelDefault.description": "Start nye samtaler med ekspertpanelet aktivert, slik at knappen kan gjennomgå gjeldende kontekst ved behov.", "settings.general.section.display": "Skjerm", "settings.general.row.language.title": "Språk", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 8ade8e49..2db7354f 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -559,12 +559,6 @@ export const dict = { "settings.general.section.updates": "Aktualizacje", "settings.general.section.sounds": "Efekty dźwiękowe", "settings.general.section.feed": "Kanał", - "settings.general.section.sharing": "Udostępnianie", - "settings.general.row.shareUrl.title": "URL serwera udostępniania", - "settings.general.row.shareUrl.description": "Podstawowy adres URL serwera używany podczas udostępniania sesji. Pozostaw puste, aby użyć domyślnego.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Domyślnie włącz panel ekspertów", - "settings.general.row.expertPanelDefault.description": "Rozpoczynaj nowe rozmowy z włączonym panelem ekspertów, aby jego przycisk mógł na żądanie przeglądać bieżący kontekst.", "settings.general.section.display": "Ekran", "settings.general.row.language.title": "Język", "settings.general.row.language.description": "Zmień język wyświetlania dla DeepAgent Code", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index c88ca046..6bd99bf4 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -624,12 +624,6 @@ export const dict = { "settings.general.section.updates": "Обновления", "settings.general.section.sounds": "Звуковые эффекты", "settings.general.section.feed": "Лента", - "settings.general.section.sharing": "Общий доступ", - "settings.general.row.shareUrl.title": "URL сервера для общего доступа", - "settings.general.row.shareUrl.description": "Базовый URL сервера, используемого при общем доступе к сеансам. Оставьте пустым для значения по умолчанию.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Включить экспертную панель по умолчанию", - "settings.general.row.expertPanelDefault.description": "Начинать новые беседы с включённой экспертной панелью, чтобы её кнопка по запросу проверяла текущий контекст.", "settings.general.section.display": "Дисплей", "settings.general.row.language.title": "Язык", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index d9822fb2..6a08684d 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -619,12 +619,6 @@ export const dict = { "settings.general.section.updates": "การอัปเดต", "settings.general.section.sounds": "เสียงเอฟเฟกต์", "settings.general.section.feed": "ฟีด", - "settings.general.section.sharing": "การแชร์", - "settings.general.row.shareUrl.title": "URL เซิร์ฟเวอร์สำหรับแชร์", - "settings.general.row.shareUrl.description": "URL พื้นฐานของเซิร์ฟเวอร์ที่ใช้เมื่อแชร์เซสชัน เว้นว่างไว้เพื่อใช้ค่าเริ่มต้น", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "เปิดใช้คณะผู้เชี่ยวชาญโดยค่าเริ่มต้น", - "settings.general.row.expertPanelDefault.description": "เริ่มการสนทนาใหม่โดยเปิดใช้คณะผู้เชี่ยวชาญ เพื่อให้ปุ่มสามารถตรวจทานบริบทปัจจุบันได้ตามต้องการ", "settings.general.section.display": "การแสดงผล", "settings.general.row.language.title": "ภาษา", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index ce03a0a0..24ffe8c6 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -630,12 +630,6 @@ export const dict = { "settings.general.section.updates": "Güncellemeler", "settings.general.section.sounds": "Ses efektleri", "settings.general.section.feed": "Akış", - "settings.general.section.sharing": "Paylaşım", - "settings.general.row.shareUrl.title": "Paylaşım sunucusu URL'si", - "settings.general.row.shareUrl.description": "Oturumları paylaşırken kullanılan sunucunun temel URL'si. Varsayılanı kullanmak için boş bırakın.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Uzman panelini varsayılan olarak etkinleştir", - "settings.general.row.expertPanelDefault.description": "Yeni sohbetleri uzman paneli etkin olarak başlatın; böylece düğmesi istendiğinde mevcut bağlamı inceler.", "settings.general.section.display": "Ekran", "settings.general.row.language.title": "Dil", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 931f58d4..6835497b 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -728,12 +728,6 @@ export const dict = { "settings.general.section.updates": "Оновлення", "settings.general.section.sounds": "Звукові ефекти", "settings.general.section.feed": "Стрічка", - "settings.general.section.sharing": "Спільний доступ", - "settings.general.row.shareUrl.title": "URL сервера спільного доступу", - "settings.general.row.shareUrl.description": "Базовий URL сервера, що використовується під час надання спільного доступу до сеансів. Залиште порожнім для типового.", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", - "settings.general.row.expertPanelDefault.title": "Типово вмикати експертну панель", - "settings.general.row.expertPanelDefault.description": "Починати нові розмови з увімкненою експертною панеллю, щоб її кнопка за потреби переглядала поточний контекст.", "settings.general.section.display": "Дисплей", "settings.general.row.language.title": "Мова", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 9facb0d1..b1d9d6cc 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -68,6 +68,9 @@ export const dict = { "session.sidePanel.toggle": "切换右侧栏", "command.terminal.toggle": "切换终端", + "command.panel.toggle": "切换底栏", + "command.debugConsole.reveal": "显示调试控制台", + "command.problems.reveal": "显示问题", "command.fileTree.toggle": "切换文件树", @@ -146,11 +149,17 @@ export const dict = { "dialog.provider.deepagent-code.tagline": "DeepAgent Code 托管模型网关", "dialog.provider.deepseek.note": "使用 API key 连接 DeepSeek 模型", "dialog.provider.zhipuai.note": "使用 API key 连接智谱 GLM 模型", + "dialog.provider.zhipuai-coding-plan.note": "使用 API key 连接智谱 GLM Coding Plan 订阅", + "dialog.provider.zai.note": "使用 API key 连接 Z.AI GLM 模型", + "dialog.provider.zai-coding-plan.note": "使用 API key 连接 Z.AI GLM Coding Plan 订阅", + "dialog.provider.kimi-for-coding.note": "使用 API key 连接 Kimi Code 订阅(Kimi for Coding)", + "dialog.provider.moonshotai-cn.note": "使用 API key 连接 Moonshot AI 开放平台 Kimi 模型", "dialog.provider.deepagentCodeGo.tagline": "适合所有人的低成本订阅", "dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 密钥连接", "dialog.provider.copilot.note": "使用 Copilot 或 API 密钥连接", "dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 密钥连接", "dialog.provider.google.note": "使用 Google 账号或 API 密钥连接", + "dialog.provider.xai.note": "使用 API key 连接 xAI Grok 模型", "dialog.provider.openrouter.note": "使用 OpenRouter 账号或 API 密钥连接", "dialog.provider.vercel.note": "使用 Vercel 账号或 API 密钥连接", @@ -705,6 +714,24 @@ export const dict = { "session.panel.oversight": "监督", "session.panel.debug": "调试", "session.panel.profile": "性能剖析", + "session.panel.terminal": "终端", + "session.panel.debugConsole": "调试控制台", + "session.panel.problems": "问题", + "session.panel.bottom": "底栏", + "session.panel.resize": "调整底栏大小", + "session.panel.moveToSide": "将视图移至右侧栏", + "session.panel.emptyBottom": "底栏中当前没有视图。", + "session.panel.noBottomViews": "请先将面板视图移到底栏。", + "session.panel.views": "面板视图", + "session.panel.location.bottom": "底栏", + "session.panel.location.side": "右侧栏", + "session.panel.showView": "显示视图", + "session.panel.moveToBottom": "移至底栏", + "session.panel.resetLocation": "重置到底栏", + "problems.error": "错误", + "problems.warning": "警告", + "problems.empty": "没有已知的工作区诊断信息。", + "problems.errorLoading": "无法加载工作区诊断信息。", "oversight.common.refresh": "刷新", "oversight.dashboard.title": "Agent 仪表盘", "oversight.dashboard.loading": "正在加载指标…", @@ -845,10 +872,17 @@ export const dict = { "terminal.title": "终端", "terminal.title.numbered": "终端 {{number}}", "terminal.close": "关闭终端", + "terminal.retry": "重试", "terminal.split": "拆分终端", + "terminal.status.starting": "正在启动终端...", + "terminal.status.connecting": "正在连接终端...", + "terminal.status.reconnecting": "正在重新连接终端...", + "terminal.status.exited": "终端进程已退出。", "terminal.pane.close": "关闭面板", "terminal.dock.terminal": "终端", "terminal.dock.debugConsole": "调试控制台", + "dock.moveToSide": "移动到侧栏面板", + "dock.moveToBottom": "移动到底栏", "terminal.debugConsole.placeholder": "调试控制台将在会话激活后可用。", "terminal.connectionLost.title": "连接已丢失", "terminal.connectionLost.description": "终端连接已中断。这可能发生在服务器重启时。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 608fadcb..ef0149c0 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -286,58 +286,6 @@ export const dict = { "prompt.attachment.remove": "移除附件", "prompt.action.send": "傳送", "prompt.action.stop": "停止", - "composer.approval.request": "請求批准", - "composer.approval.readOnly": "唯讀", - "composer.approval.fullAccess": "完全存取", - "composer.mode.auto": "自動", - "composer.mode.loop": "目標", - "composer.mode.design": "設計", - "composer.mode.auto.desc": "智能體自行定目標、做計劃並執行到完成", - "composer.mode.loop.desc": "你說需求,智能體生成 goal+plan.md,再由監督循環自主執行到達成", - "composer.mode.design.desc": "你自己寫好 goal+plan.md,智能體讀取並忠實執行你的方案", - "composer.panel.label": "專家團", - "composer.panel.off": "關閉", - "composer.panel.single": "單輪評審", - "composer.panel.multi": "多輪辯論", - "composer.panel.verdict.title": "專家團裁定", - "composer.panel.verdict.approve": "批准", - "composer.panel.verdict.revise": "修訂", - "composer.panel.verdict.block": "阻止", - "composer.panel.verdict.needsHuman": "需人工介入", - "composer.panel.verdict.meta.one": "{{confidence}}% 置信度 · {{rounds}} 輪", - "composer.panel.verdict.meta.other": "{{confidence}}% 置信度 · {{rounds}} 輪", - "composer.panel.verdict.evidence": "依據", - "composer.panel.verdict.dissent": "異議(已被否決)", - "composer.goal.phase.running": "執行中", - "composer.goal.phase.paused": "已暫停", - "composer.goal.phase.done": "已完成", - "composer.goal.phase.needsHuman": "需你處理", - "composer.goal.phase.rolledBack": "已回滾", - "composer.goal.phase.stopped": "已停止", - "composer.goal.budget.one": "{{ticks}} 次 · {{tokens}} tokens", - "composer.goal.budget.other": "{{ticks}} 次 · {{tokens}} tokens", - "composer.goal.pause": "暫停", - "composer.goal.resume": "繼續", - "composer.goal.stop": "停止", - "composer.goal.dismiss": "忽略", - "composer.goal.editPlan": "編輯計劃", - "composer.goal.editPlan.title": "編輯目標計劃", - "composer.goal.editPlan.desc": "修改正在執行的目標計劃。變更將在下一個 tick 生效。", - "composer.goal.editPlan.goalLabel": "目標", - "composer.goal.editPlan.stepsLabel": "步驟", - "composer.goal.editPlan.addStep": "新增步驟", - "composer.goal.editPlan.removeStep": "刪除步驟", - "composer.goal.editPlan.stepPlaceholder": "步驟描述", - "composer.goal.editPlan.save": "套用變更", - "composer.goal.editPlan.cancel": "取消", - "composer.goal.editPlan.saved": "計劃已更新——下一個 tick 生效", - "composer.goal.editPlan.failed": "無法更新計劃(目標未在執行?)", - "composer.goal.editPlan.status.pending": "待處理", - "composer.goal.editPlan.status.active": "進行中", - "composer.goal.editPlan.status.done": "已完成", - "composer.goal.editPlan.status.blocked": "受阻", - "composer.goal.editPlan.status.cancelled": "已取消", - "composer.steer.hint": "工作階段正在執行——你的訊息將引導目前回合", "prompt.toast.pasteUnsupported.title": "不支援的附件", "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。", @@ -378,8 +326,6 @@ export const dict = { "dialog.server.add.error": "無法連線到伺服器", "dialog.server.add.checking": "檢查中...", "dialog.server.add.button": "新增伺服器", - "dialog.server.add.menu.http": "新增 HTTP 伺服器", - "dialog.server.connect.button": "連線到伺服器", "dialog.server.add.name": "伺服器名稱(選填)", "dialog.server.add.namePlaceholder": "Localhost", "dialog.server.add.username": "使用者名稱(選填)", @@ -578,66 +524,6 @@ export const dict = { "session.files.all": "所有檔案", "session.files.empty": "沒有檔案", "session.files.binaryContent": "二進位檔案(無法顯示內容)", - "session.files.heading": "檔案", - "session.files.newFile": "新增檔案", - "session.files.newFolder": "新增資料夾", - "session.files.newFile.placeholder": "檔案名稱", - "session.files.newFolder.placeholder": "資料夾名稱", - "session.files.create.confirm": "確認", - "session.files.create.cancel": "取消", - "session.files.create.failed": "建立失敗", - "session.files.createFolder.failed": "建立資料夾失敗", - "session.panel.oversight": "監督", - "session.panel.debug": "偵錯", - "session.panel.profile": "效能分析", - "oversight.common.refresh": "重新整理", - "oversight.dashboard.title": "Agent 儀表板", - "oversight.dashboard.loading": "正在載入指標…", - "oversight.dashboard.empty": "暫無可用指標。", - "oversight.metric.taskSuccessRate": "任務成功率", - "oversight.metric.conflictRate": "衝突率", - "oversight.metric.dlqEvents": "死信事件", - "oversight.metric.pushRejected": "推送被拒", - "oversight.metric.tasksCompleted": "已完成任務", - "oversight.metric.tasksFailed": "失敗任務", - "oversight.metric.publishLatencyP50": "發布延遲 P50", - "oversight.metric.publishLatencyP95": "發布延遲 P95", - "oversight.metric.eventToAgentP50": "事件→Agent P50", - "oversight.metric.eventToAgentP95": "事件→Agent P95", - "oversight.metric.humanTakeovers": "人工接管次數", - "oversight.metric.rollbacks": "回滾次數", - "oversight.metric.pushRejectedByReason": "按原因統計的推送拒絕", - "oversight.metric.window": "時間視窗:{{from}} → {{to}}", - "oversight.approvals.title": "審批佇列", - "oversight.approvals.empty": "沒有待審批項。", - "oversight.approvals.approve": "批准", - "oversight.approvals.reject": "拒絕", - "oversight.approvals.acknowledge": "知悉", - "oversight.approvals.viewTrace": "檢視軌跡", - "oversight.approvals.resolveFailed": "處理失敗", - "oversight.trace.title": "事件軌跡", - "oversight.trace.placeholder": "correlationID…", - "oversight.trace.action": "追蹤", - "oversight.trace.loading": "正在載入軌跡…", - "oversight.trace.empty": "該 correlationID 沒有事件。", - "oversight.trace.source": "來源:", - "oversight.trace.causedBy": "起因:", - "oversight.takeover.title": "人工接管", - "oversight.takeover.description": "記錄由人工從自主 Agent 處接管(暫停自主性升級)。", - "oversight.takeover.placeholder": "接管原因…", - "oversight.takeover.action": "接管", - "oversight.takeover.recorded": "接管已記錄。", - "oversight.takeover.unsupported": "接管記錄將在後端端點(P3.10)可用後啟用。", - "oversight.takeover.failed": "失敗:{{error}}", - "oversight.rollback.title": "回滾", - "oversight.rollback.description": "回滾某個工作階段中 Agent 產生的變更(透過 SessionRevert)。僅影響本工作區內的工作階段。此操作會回滾真實變更——請謹慎使用。", - "oversight.rollback.sessionPlaceholder": "工作階段 ID(ses_…)", - "oversight.rollback.reasonPlaceholder": "回滾原因…(可選)", - "oversight.rollback.action": "回滾", - "oversight.rollback.noop": "無內容可回滾——已記錄為空操作。", - "oversight.rollback.applied": "已回滾——工作階段已還原。", - "oversight.rollback.notFound": "本工作區內無此工作階段。", - "oversight.rollback.failed": "失敗:{{error}}", "session.messages.renderEarlier": "顯示更早的訊息", "session.messages.loadingEarlier": "正在載入更早的訊息...", "session.messages.loadEarlier": "載入更早的訊息", @@ -723,32 +609,6 @@ export const dict = { "sidebar.nav.projectsAndSessions": "專案與工作階段", "sidebar.settings": "設定", "sidebar.help": "說明", - "sidebar.wiki": "倉庫與百科", - "review.scope.project": "當前專案", - "review.scope.global": "全域", - "goal.start.hint": "計劃已就緒 — 轉為受監督的長跑目標", - "goal.start.button": "轉為 Goal", - "goal.start.success": "目標已啟動,正在背景執行", - "goal.start.failed": "無法啟動目標", - "wiki.title": "倉庫與百科", - "wiki.description": "閱讀、檢索、治理四張圖。知識與記憶可編輯;文件與程式碼唯讀。", - "wiki.search": "搜尋頁面與符號", - "wiki.searchEmpty": "沒有符合的頁面", - "wiki.empty": "暫無頁面", - "wiki.loading": "載入中…", - "wiki.selectPrompt": "選擇一個頁面閱讀", - "wiki.readOnly": "唯讀", - "wiki.version": "v{{version}}", - "wiki.links": "關聯程式碼與文件", - "wiki.stale": "已失效", - "wiki.type.knowledge": "知識", - "wiki.type.memory": "記憶", - "wiki.type.document": "文件", - "wiki.type.code": "程式碼", - "wiki.edit.button": "編輯", - "wiki.edit.save": "儲存", - "wiki.edit.saved": "知識頁已更新", - "wiki.edit.failed": "儲存失敗", "sidebar.workspaces.enable": "啟用工作區", "sidebar.workspaces.disable": "停用工作區", "sidebar.gettingStarted.title": "開始使用", @@ -773,10 +633,6 @@ export const dict = { "settings.general.section.sounds": "音效", "settings.general.section.feed": "資訊流", "settings.general.section.display": "顯示", - "settings.general.section.sharing": "分享", - "settings.general.row.shareUrl.title": "分享伺服器網址", - "settings.general.row.shareUrl.description": "分享工作階段時使用的伺服器基礎網址。留空則使用預設值。", - "settings.general.row.shareUrl.placeholder": "https://opncd.ai", "settings.general.row.language.title": "語言", "settings.general.row.language.description": "變更 DeepAgent Code 的顯示語言", @@ -805,8 +661,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", "settings.general.row.showSessionProgressBar.title": "顯示工作階段進度列", "settings.general.row.showSessionProgressBar.description": "當代理程式正在運作時,在工作階段頂部顯示動畫進度列", - "settings.general.row.expertPanelDefault.title": "預設啟用專家團", - "settings.general.row.expertPanelDefault.description": "新工作階段預設啟用專家團,其按鈕可隨時對目前上下文發起會診", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。", "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。", diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 5f6517aa..abef9f0b 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -4,16 +4,10 @@ import { base64Encode } from "@deepagent-code/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" -import { useServer } from "@/context/server" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" import { useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" -import { isFilesystemRootDir, recoverFilesystemRootRoute } from "@/utils/filesystem-root" -import { formatServerError } from "@/utils/server-errors" import { Schema } from "effect" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { @@ -59,13 +53,8 @@ export function decodeDirectory(dir: string): ProjectDirString | undefined { export default function Layout(props: ParentProps) { const params = useParams() const language = useLanguage() - const layout = useLayout() const navigate = useNavigate() - const server = useServer() - const serverSDK = useServerSDK() - const serverSync = useServerSync() let invalid = "" - let recovering = "" const resolved = createMemo(() => { if (!params.dir) return "" @@ -89,51 +78,8 @@ export default function Layout(props: ParentProps) { navigate("/", { replace: true }) }) - createEffect(() => { - const directory = resolved() - if (!directory || !isFilesystemRootDir(directory)) return - const dataDir = serverSync.data.path.data - if (!dataDir) return - const key = `${params.dir}/${params.id ?? ""}` - if (recovering === key) return - recovering = key - - void recoverFilesystemRootRoute({ - dataDir, - sessionID: params.id, - getSession: (sessionID) => - serverSDK.client.session - .get({ sessionID }) - .then((response) => response.data) - .catch(() => undefined), - mkdir: async (destination) => { - await serverSDK.createClient({ directory: destination, throwOnError: true }).file.mkdir({ path: "." }) - }, - moveSession: async (sessionID, destination) => { - await serverSDK.client.experimental.controlPlane.moveSession({ - sessionID, - destination: { directory: destination }, - }) - }, - }) - .then((result) => { - layout.projects.open(result.directory) - server.projects.touch(result.directory) - const session = result.sessionID ? `/session/${result.sessionID}` : "/session" - navigate(`/${base64Encode(result.directory)}${session}`, { replace: true }) - }) - .catch((error) => { - showToast({ - variant: "error", - title: language.t("toast.project.rootRecoveryFailed.title"), - description: formatServerError(error, language.t), - }) - navigate("/", { replace: true }) - }) - }) - return ( - + {(resolved) => ( {props.children} diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 3b6448e1..7c0e4ed5 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -6,6 +6,7 @@ import { Component, createSignal, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" +import { Icon } from "@deepagent-code/ui/icon" import { errorDescriptionKey } from "./error-description" export type InitError = { @@ -15,7 +16,6 @@ export type InitError = { type Translator = ReturnType["t"] const CHAIN_SEPARATOR = "\n" + "─".repeat(40) + "\n" -const OFFICIAL_FEEDBACK_URL: string = "" function isIssue(value: unknown): value is { message: string; path: string[] } { if (!value || typeof value !== "object") return false @@ -346,20 +346,17 @@ export const ErrorPage: Component = (props) => { {(message) =>

{message()}

}
- - {(url) => ( -
- {language.t("error.page.report.prefix")} - -
- )} -
+
+ {language.t("error.page.report.prefix")} + +
{(version) => (

{language.t("error.page.version", { version: version() })}

diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2b619136..548a213d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -10,6 +10,7 @@ import { createMemo, createEffect, createComputed, + createSignal, on, onMount, untrack, @@ -35,7 +36,7 @@ import { useComments } from "@/context/comments" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" -import { RIGHT_PANEL_RAIL_PX, useLayout } from "@/context/layout" +import { useLayout } from "@/context/layout" import { usePrompt } from "@/context/prompt" import { deepAgentModeFromConfig } from "@/utils/deepagent-settings" import { useSDK } from "@/context/sdk" @@ -230,6 +231,8 @@ export default function Page() { }, }) + const [fileNavigator, setFileNavigator] = createSignal<(filePath: string, line: number) => void>() + const composer = createSessionComposerState() const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) @@ -303,18 +306,10 @@ export default function Page() { const desktopReviewOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "review") const desktopFileTreeOpen = createMemo(() => isDesktop() && view().rightPanel.mode() === "files") const desktopRightPanelOpen = createMemo(() => isDesktop() && view().rightPanel.opened()) - // T3.2: the icon rail is ALWAYS present on desktop, so the session content always yields the rail - // width; the content-panel width (wide bucket for review/files, else narrow) is subtracted on top only - // when a panel is open. const sessionPanelWidth = createMemo(() => { - if (!isDesktop()) return "100%" - const mode = view().rightPanel.mode() - const bucket = mode === "review" || mode === "files" ? "wide" : "narrow" - const content = desktopRightPanelOpen() ? layout.rightPanel.width(bucket) : 0 - return `calc(100% - ${RIGHT_PANEL_RAIL_PX + content}px)` + if (!desktopRightPanelOpen()) return "100%" + return `calc(100% - ${layout.rightPanel.width()}px)` }) - // The composer is only truly centered when the panel content is closed AND the rail (a thin 44px - // strip) is the only thing on the right — visually still effectively centered. const centered = createMemo(() => isDesktop() && !desktopRightPanelOpen()) function normalizeTab(tab: string) { @@ -850,7 +845,13 @@ export default function Page() { } // Prefer the open terminal over the composer when it can take focus - if (view().terminal.opened()) { + const terminalVisible = (() => { + const panel = view().panel + return panel.location("terminal") === "bottom" + ? panel.bottom.opened() && panel.bottom.activeView() === "terminal" + : view().rightPanel.mode() === "terminal" + })() + if (terminalVisible) { const id = terminal.active() if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return } @@ -1893,6 +1894,7 @@ export default function Page() {
setFileNavigator(() => navigate)} canReview={canReview} diffs={reviewDiffs} diffsReady={reviewReady} @@ -1907,7 +1909,7 @@ export default function Page() { />
- + fileNavigator()?.(path, line)} />
) } diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 23217bcb..eeb8fee2 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -260,9 +260,14 @@ export function SessionComposerRegion(props: { + {/* S41-1: the composer stack is pulled up over the todo dock by a negative margin + (the "sacrificial overlap" the opaque PromptInput normally absorbs). A transparent + hint row inside that zone draws directly over the todo window's scrolled content. + Give the hint its own opaque surface so it reads as a reserved status row + (codex-style: hint occupies its own row, never blended into other content). */}
{language.t("composer.steer.hint")} diff --git a/packages/app/src/pages/session/im-panel-helpers.test.ts b/packages/app/src/pages/session/im-panel-helpers.test.ts index c2c1fef9..4fcfe1c3 100644 --- a/packages/app/src/pages/session/im-panel-helpers.test.ts +++ b/packages/app/src/pages/session/im-panel-helpers.test.ts @@ -16,9 +16,7 @@ const group = (id: string, name: string): IMGroup => ({ describe("submitCreateGroup", () => { test("creates a group via the client (never window.prompt)", async () => { - const createGroup = mock(async (p: { name: string; type: "project" | "system" | "direct" }) => - group("grp_1", p.name), - ) + const createGroup = mock(async (p: { name: string; type: "project" | "system" }) => group("grp_1", p.name)) const result = await submitCreateGroup(" Design ", createGroup) expect(createGroup).toHaveBeenCalledTimes(1) diff --git a/packages/app/src/pages/session/im-panel-helpers.ts b/packages/app/src/pages/session/im-panel-helpers.ts index 63c7526a..7dad9b3b 100644 --- a/packages/app/src/pages/session/im-panel-helpers.ts +++ b/packages/app/src/pages/session/im-panel-helpers.ts @@ -11,12 +11,7 @@ export type CreateGroupResult = export async function submitCreateGroup( rawName: string, - createGroup: (payload: { - name: string - type: "project" | "system" | "direct" - projectID?: string - member?: { memberID: string; memberType: "user" | "agent" } - }) => Promise, + createGroup: (payload: { name: string; type: "project" | "system"; projectID?: string }) => Promise, ): Promise { const name = rawName.trim() if (!name) return { skipped: true } @@ -27,25 +22,3 @@ export async function submitCreateGroup( return { error: error instanceof Error ? error.message : String(error) } } } - -// §B3 私聊 — create a "direct" 1:1 group with a counterparty (a user or an agent). The server user is -// always the first participant; `member` names the second. Returns the same result shape. -export async function submitCreateDirect( - name: string, - member: { memberID: string; memberType: "user" | "agent" }, - createGroup: (payload: { - name: string - type: "project" | "system" | "direct" - projectID?: string - member?: { memberID: string; memberType: "user" | "agent" } - }) => Promise, -): Promise { - const trimmed = name.trim() || member.memberID - if (!member.memberID.trim()) return { skipped: true } - try { - const group = await createGroup({ name: trimmed, type: "direct", member }) - return { group } - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) } - } -} diff --git a/packages/app/src/pages/session/panel-view-registry.ts b/packages/app/src/pages/session/panel-view-registry.ts new file mode 100644 index 00000000..37837618 --- /dev/null +++ b/packages/app/src/pages/session/panel-view-registry.ts @@ -0,0 +1,9 @@ +import type { ComponentProps } from "solid-js" +import { Icon } from "@deepagent-code/ui/icon" +import type { DockPanelID } from "@/context/layout" + +export const PANEL_VIEW_META: Record["name"]; titleKey: string }> = { + terminal: { icon: "terminal-active", titleKey: "session.panel.terminal" }, + "debug-console": { icon: "code-lines", titleKey: "session.panel.debugConsole" }, + problems: { icon: "warning", titleKey: "session.panel.problems" }, +} diff --git a/packages/app/src/pages/session/problems-helpers.ts b/packages/app/src/pages/session/problems-helpers.ts new file mode 100644 index 00000000..6d022561 --- /dev/null +++ b/packages/app/src/pages/session/problems-helpers.ts @@ -0,0 +1,58 @@ +export type Position = { line: number; character: number } +export type LspDiagnostic = { + range: { start: Position; end: Position } + severity?: number + message: string + source?: string + code?: string | number +} + +export type ProblemLevel = "error" | "warning" | "information" | "hint" + +export type Problem = LspDiagnostic & { + file: string + relativeFile: string + level: ProblemLevel +} + +const severityOrder: Record = { error: 0, warning: 1, information: 2, hint: 3 } + +const severityFor = (severity: number | undefined): ProblemLevel => { + if (severity === 1 || severity === undefined) return "error" + if (severity === 2) return "warning" + if (severity === 3) return "information" + return "hint" +} + +export const parseWorkspaceDiagnostics = (value: unknown, relative: (path: string) => string): Problem[] => { + if (!value || typeof value !== "object" || Array.isArray(value)) return [] + const output: Problem[] = [] + for (const [file, diagnostics] of Object.entries(value)) { + if (!Array.isArray(diagnostics)) continue + for (const diagnostic of diagnostics) { + if (!diagnostic || typeof diagnostic !== "object") continue + const item = diagnostic as Partial + if ( + typeof item.message !== "string" || + !item.range || + typeof item.range.start?.line !== "number" || + typeof item.range.start?.character !== "number" + ) { + continue + } + output.push({ + ...(item as LspDiagnostic), + file, + relativeFile: relative(file), + level: severityFor(item.severity), + }) + } + } + return output.sort( + (a, b) => + severityOrder[a.level] - severityOrder[b.level] || + a.relativeFile.localeCompare(b.relativeFile) || + a.range.start.line - b.range.start.line || + a.range.start.character - b.range.start.character, + ) +} diff --git a/packages/app/src/pages/session/problems-panel.test.ts b/packages/app/src/pages/session/problems-panel.test.ts new file mode 100644 index 00000000..dd05dbdc --- /dev/null +++ b/packages/app/src/pages/session/problems-panel.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { parseWorkspaceDiagnostics } from "./problems-helpers" + +describe("parseWorkspaceDiagnostics", () => { + test("validates unknown SDK payloads and sorts by severity, file, and range", () => { + const diagnostics = parseWorkspaceDiagnostics( + { + "/workspace/z.ts": [ + { message: "late warning", severity: 2, range: { start: { line: 4, character: 2 }, end: { line: 4, character: 3 } } }, + ], + "/workspace/a.ts": [ + { message: "error", severity: 1, source: "ts", code: 2322, range: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }, + { message: "early warning", severity: 2, range: { start: { line: 0, character: 1 }, end: { line: 0, character: 2 } } }, + ], + "/workspace/ignored.ts": [{ message: 1 }], + }, + (path) => path.replace("/workspace/", ""), + ) + + expect(diagnostics.map((item) => [item.level, item.relativeFile, item.range.start.line])).toEqual([ + ["error", "a.ts", 1], + ["warning", "a.ts", 0], + ["warning", "z.ts", 4], + ]) + expect(diagnostics[0]).toMatchObject({ source: "ts", code: 2322 }) + }) + + test("treats omitted severity as error and ignores malformed root values", () => { + expect(parseWorkspaceDiagnostics(null, String)).toEqual([]) + expect(parseWorkspaceDiagnostics([], String)).toEqual([]) + expect( + parseWorkspaceDiagnostics( + { "/workspace/a.ts": [{ message: "unknown severity", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }] }, + String, + )[0]?.level, + ).toBe("error") + }) +}) diff --git a/packages/app/src/pages/session/problems-panel.tsx b/packages/app/src/pages/session/problems-panel.tsx new file mode 100644 index 00000000..eda4da12 --- /dev/null +++ b/packages/app/src/pages/session/problems-panel.tsx @@ -0,0 +1,86 @@ +import { For, Show, createMemo, createResource, type Accessor } from "solid-js" +import { Icon } from "@deepagent-code/ui/icon" +import { IconButton } from "@deepagent-code/ui/icon-button" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" + +import { parseWorkspaceDiagnostics, type Problem, type ProblemLevel } from "@/pages/session/problems-helpers" + +export function ProblemsPanel(props: { active: Accessor; onOpenFile: (path: string, line: number) => void }) { + const sdk = useSDK() + const file = useFile() + const language = useLanguage() + const [diagnostics, { refetch }] = createResource( + props.active, + async () => { + const response = await sdk.client.lsp.diagnostics() + return parseWorkspaceDiagnostics(response.data, (path) => file.normalize(path)) + }, + ) + + const groups = createMemo(() => { + const map = new Map() + for (const problem of diagnostics() ?? []) { + const existing = map.get(problem.file) ?? [] + existing.push(problem) + map.set(problem.file, existing) + } + return Array.from(map.entries()).map(([path, problems]) => ({ path, relativeFile: problems[0]!.relativeFile, problems })) + }) + const counts = createMemo(() => { + const initial: Record = { error: 0, warning: 0, information: 0, hint: 0 } + for (const problem of diagnostics() ?? []) initial[problem.level]++ + return initial + }) + + return ( +
+
+
{language.t("session.panel.problems")}
+
+ {counts().error} {language.t("problems.error")} + {counts().warning} {language.t("problems.warning")} +
+ void refetch()} /> +
+ +
{language.t("common.loading")}{language.t("common.loading.ellipsis")}
+
+ +
+
{language.t("problems.errorLoading")}
+ +
+
+ +
{language.t("problems.empty")}
+
+ + {(group) => ( +
+
{group.relativeFile}
+ + {(problem) => ( + + )} + +
+ )} +
+
+ ) +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 424b88e0..e225bf22 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -6,7 +6,7 @@ import { Icon } from "@deepagent-code/ui/icon" import { Tooltip } from "@deepagent-code/ui/tooltip" import { ResizeHandle } from "@deepagent-code/ui/resize-handle" import type { SnapshotFileDiff, VcsFileDiff } from "@deepagent-code/sdk/v2" -import { RIGHT_PANEL_RAIL_PX, type RightPanelWidthBucket } from "@/context/layout" +import { RIGHT_PANEL_RAIL_PX, type RightPanelWidthBucket, type DockPanelID } from "@/context/layout" import FileTree from "@/components/file-tree" import { SidePanelMcp } from "@/pages/session/side-panel-mcp" @@ -30,6 +30,8 @@ import { SidePanelDebug } from "@/pages/session/side-panel-debug" import { SidePanelProfile } from "@/pages/session/side-panel-profile" import { SidePanelIM } from "@/pages/session/side-panel-im" import { SidePanelOversight } from "@/pages/session/side-panel-oversight" +import { SidePanelDockHeader, SidePanelTerminal, SidePanelDebugConsole } from "@/pages/session/side-panel-terminal" +import { ProblemsPanel } from "@/pages/session/problems-panel" type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff @@ -49,8 +51,15 @@ type PanelMode = | "plugins" | "debug" | "profile" + | "terminal" + | "debug-console" + | "problems" -type PanelGroup = "code" | "agents" | "env" | "dev" +type PanelGroup = "code" | "agents" | "env" | "dev" | "dock" + +// Movable panel views appear in this rail only when their persisted location is +// side. Other entries remain side-native. +const DOCK_PANEL_MODES: readonly DockPanelID[] = ["terminal", "debug-console", "problems"] type PanelDef = { readonly mode: PanelMode @@ -82,9 +91,13 @@ const PANELS: readonly PanelDef[] = [ { mode: "plugins", icon: "plugin", titleKey: "status.popover.tab.plugins", group: "dev", bucket: "narrow" }, { mode: "debug", icon: "debug", titleKey: "session.panel.debug", group: "dev", bucket: "narrow" }, { mode: "profile", icon: "profile", titleKey: "session.panel.profile", group: "dev", bucket: "narrow" }, + // Dock — the movable panels; only surface here when docked to the side (see gating below). + { mode: "terminal", icon: "terminal-active", titleKey: "session.panel.terminal", group: "dock", bucket: "narrow", keybind: "terminal.toggle" }, + { mode: "debug-console", icon: "code-lines", titleKey: "session.panel.debugConsole", group: "dock", bucket: "narrow" }, + { mode: "problems", icon: "warning", titleKey: "session.panel.problems", group: "dock", bucket: "narrow" }, ] -const GROUP_ORDER: readonly PanelGroup[] = ["code", "agents", "env", "dev"] +const GROUP_ORDER: readonly PanelGroup[] = ["code", "agents", "env", "dev", "dock"] function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { return typeof value.file === "string" @@ -102,6 +115,7 @@ export function SessionSidePanel(props: { focusReviewDiff: (path: string) => void reviewSnap: boolean size: Sizing + onFileNavigate?: (navigate: (filePath: string, line: number) => void) => void }) { const layout = useLayout() const file = useFile() @@ -252,6 +266,7 @@ export function SessionSidePanel(props: { setGotoLine(undefined) queueMicrotask(() => setGotoLine(line)) } + createEffect(() => props.onFileNavigate?.(openFileAt)) // V3.6 Phase 1B F5 — inline new-file/folder creation state const [newItemState, setNewItemState] = createSignal<{ type: "file" | "dir"; name: string } | null>(null) @@ -292,6 +307,10 @@ export function SessionSidePanel(props: { const openPanel = (mode: PanelMode) => { view().reviewPanel.close() layout.fileTree.close() + if (DOCK_PANEL_MODES.includes(mode as DockPanelID)) { + view().panel.toggle(mode as DockPanelID) + return + } if (isActive(mode)) { view().rightPanel.close() return @@ -312,13 +331,17 @@ export function SessionSidePanel(props: { return undefined } const railItems = createMemo(() => - PANELS.filter((p) => (p.capability === "oversight" ? oversightEnabled() : true)).map((p) => ({ - ...p, - title: language.t(p.titleKey), - keybindLabel: p.keybind ? command.keybind(p.keybind) : undefined, - active: isActive(p.mode), - badge: badgeFor(p.mode), - })), + PANELS.filter((p) => (p.capability === "oversight" ? oversightEnabled() : true)) + // Dock panels (terminal / debug-console) only appear in the rail when docked to the side; when + // in the bottom dock they're reached there instead. + .filter((p) => (DOCK_PANEL_MODES.includes(p.mode as DockPanelID) ? view().panel.location(p.mode as DockPanelID) === "side" : true)) + .map((p) => ({ + ...p, + title: language.t(p.titleKey), + keybindLabel: p.keybind ? command.keybind(p.keybind) : undefined, + active: isActive(p.mode), + badge: badgeFor(p.mode), + })), ) createEffect(() => { @@ -561,6 +584,20 @@ export function SessionSidePanel(props: { + + view().panel.toggle("terminal")} /> + + + view().panel.toggle("debug-console")} /> + + +
+ view().panel.toggle("problems")} /> +
+ isActive("problems")} onOpenFile={openFileAt} /> +
+
+
diff --git a/packages/app/src/pages/session/side-panel-im.tsx b/packages/app/src/pages/session/side-panel-im.tsx index 28916f18..eb58ed0d 100644 --- a/packages/app/src/pages/session/side-panel-im.tsx +++ b/packages/app/src/pages/session/side-panel-im.tsx @@ -1,14 +1,11 @@ -import { createEffect, createResource, createSignal, For, Show, type Component } from "solid-js" +import { createEffect, createSignal, For, Show, type Component } from "solid-js" import { IconButton } from "@deepagent-code/ui/icon-button" import { InlineInput } from "@deepagent-code/ui/inline-input" import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" import { GroupChatPanel } from "@/components/im/group-chat-panel" -import { MessageSearch } from "@/components/im/message-search" -import { fetchIMCapabilities } from "@/components/im/capabilities" import { useIMClient } from "@/utils/im-client" import type { IMGroup } from "@/components/im/types" -import { submitCreateDirect, submitCreateGroup } from "@/pages/session/im-panel-helpers" +import { submitCreateGroup } from "@/pages/session/im-panel-helpers" // IM as a right-side-panel tab. Mirrors the other side-panels (browser, subagents): // owns a header + close button (calls onClose), fills the panel with `h-full` @@ -17,23 +14,12 @@ import { submitCreateDirect, submitCreateGroup } from "@/pages/session/im-panel- export const SidePanelIM: Component<{ onClose: () => void }> = (props) => { const language = useLanguage() const client = useIMClient() - const sdk = useSDK() const [groups, setGroups] = createSignal([]) const [loading, setLoading] = createSignal(true) const [selectedGroupID, setSelectedGroupID] = createSignal(null) const [creatingName, setCreatingName] = createSignal(null) const [busy, setBusy] = createSignal(false) - const [searching, setSearching] = createSignal(false) - // §B3 direct message — the inline counterparty editor state (null = closed). - const [directTarget, setDirectTarget] = createSignal(null) - - // §B3/§H3 capability gate — thread view + file upload only where the server's flags are ON. - const [capabilities] = createResource(() => - fetchIMCapabilities(sdk.client as unknown as Parameters[0]), - ) - const threadsEnabled = () => capabilities()?.v4ThreadEnabled ?? false - const fileUploadEnabled = () => capabilities()?.v4FileUploadEnabled ?? false const loadGroups = () => { setLoading(true) @@ -79,43 +65,6 @@ export const SidePanelIM: Component<{ onClose: () => void }> = (props) => { setCreatingName(null) } - const startDirect = () => setDirectTarget("") - const cancelDirect = () => setDirectTarget(null) - - const submitDirect = async () => { - const target = directTarget()?.trim() ?? "" - if (!target) { - cancelDirect() - return - } - // A leading "@" marks an agent counterparty; otherwise a user id. - const isAgent = target.startsWith("@") - const memberID = isAgent ? target.slice(1) : target - setBusy(true) - const result = await submitCreateDirect( - memberID, - { memberID, memberType: isAgent ? "agent" : "user" }, - (payload) => client.createGroup(payload), - ) - setBusy(false) - if ("skipped" in result) { - cancelDirect() - return - } - if ("error" in result) { - const { showToast } = await import("@/utils/toast") - showToast({ - variant: "error", - title: language.t("im.group.create.failed"), - description: result.error, - }) - return - } - setGroups((prev) => [...prev, result.group]) - setSelectedGroupID(result.group.id) - setDirectTarget(null) - } - const backToList = () => setSelectedGroupID(null) return ( @@ -135,22 +84,6 @@ export const SidePanelIM: Component<{ onClose: () => void }> = (props) => {
- setSearching((v) => !v)} - aria-label="Search messages" - /> - void }> = (props) => { when={selectedGroupID()} fallback={
- {/* §B3 search — opens a message search over the caller's group memberships. Selecting a - result jumps to its group. */} - -
- { - setSearching(false) - setSelectedGroupID(groupID) - }} - /> -
-
- {/* §B3 direct message — inline counterparty editor. Prefix "@" for an agent. */} - -
- queueMicrotask(() => el.isConnected && el.focus())} - class="w-full rounded-md border border-border-weak-base bg-surface-panel px-2 py-1 text-13-regular outline-none" - value={directTarget() ?? ""} - placeholder="User id, or @agent-id" - disabled={busy()} - onInput={(event) => setDirectTarget(event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") void submitDirect() - else if (event.key === "Escape") cancelDirect() - }} - onBlur={() => { - if (!busy()) cancelDirect() - }} - /> -
-
void }> = (props) => { } > {(id) => ( -
- +
+
)} diff --git a/packages/app/src/pages/session/side-panel-plugins.tsx b/packages/app/src/pages/session/side-panel-plugins.tsx index e077096d..60e2c2b3 100644 --- a/packages/app/src/pages/session/side-panel-plugins.tsx +++ b/packages/app/src/pages/session/side-panel-plugins.tsx @@ -1,7 +1,6 @@ import { Component, createMemo, For, type JSXElement, Show } from "solid-js" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" -import { Icon } from "@deepagent-code/ui/icon" import { IconButton } from "@deepagent-code/ui/icon-button" const pluginEmptyMessage = (value: string, file: string): JSXElement => { @@ -30,10 +29,7 @@ export const SidePanelPlugins: Component<{ onClose: () => void }> = (props) => { return (
- - - {language.t("status.popover.tab.plugins")} - + {language.t("status.popover.tab.plugins")} void }> = (props) => { {/* ── Header ── */}
- + 性能剖析 void + actions?: () => JSX.Element +}) { + const language = useLanguage() + const { view } = useSessionLayout() + const move = () => view().panel.move(props.id, "bottom") + return ( +
+
{props.title}
+
+ {(actions) => actions()()} + +
+ ) +} + +/** Terminal hosted in the right side panel. PTY lifecycle is owned by TerminalPanel; + * this component only provides the side-panel host. */ +export function SidePanelTerminal(props: { + onClose: () => void +}) { + const language = useLanguage() + const terminal = useTerminal() + const { view } = useSessionLayout() + const terminalReady = () => view().panel.location("terminal") === "side" && terminal.ready() + + return ( +
+ } + /> +
+ {language.t("terminal.loading")}
} + > + + +
+
+ ) +} + +/** Debug console hosted in the right side panel. */ +export function SidePanelDebugConsole(props: { onClose: () => void }) { + const language = useLanguage() + return ( +
+ +
+ +
+
+ ) +} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 92605f6d..c7cd9d72 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,374 +1,73 @@ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { For, Show, Switch, Match, createEffect, createMemo, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" -import { Tabs } from "@deepagent-code/ui/tabs" import { ResizeHandle } from "@deepagent-code/ui/resize-handle" +import { Icon } from "@deepagent-code/ui/icon" import { IconButton } from "@deepagent-code/ui/icon-button" -import { TooltipKeybind, Tooltip } from "@deepagent-code/ui/tooltip" -import { DragDropProvider, DragDropSensors, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" -import { ConstrainDragYAxis } from "@/utils/solid-dnd" - -import { SortableTerminalTab } from "@/components/session" -import { Terminal } from "@/components/terminal" -import { useCommand } from "@/context/command" -import { useDebug } from "@/context/debug" +import { Tooltip } from "@deepagent-code/ui/tooltip" import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { useTerminal, type PaneLeaf, type PaneNode } from "@/context/terminal" +import { useLayout, type DockPanelID } from "@/context/layout" +import { useTerminal } from "@/context/terminal" import { terminalTabLabel } from "@/pages/session/terminal-label" -import { createSizing, focusTerminalById } from "@/pages/session/helpers" -import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" +import { createSizing } from "@/pages/session/helpers" +import { setTerminalHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import { DebugConsole, TerminalActions, TerminalPanes, useTerminalLifecycle } from "@/pages/session/terminal-view" +import { ProblemsPanel } from "@/pages/session/problems-panel" +import { PANEL_VIEW_META } from "@/pages/session/panel-view-registry" -/** Bottom dock can host several kinds of panel; terminal today, debug console next (Phase 4.3). */ -type DockTabKind = "terminal" | "debug-console" - -/** V3.7 Phase 4.5: Debug Console — renders the shared debug output stream. */ -function DebugConsole() { - const debug = useDebug() - const language = useLanguage() - let scroller: HTMLDivElement | undefined - let atBottom = true - - const categoryColor = (c: string) => - c === "stderr" ? "text-red-400" : c === "console" ? "text-blue-400" : "text-text-base" - - // Auto-scroll to bottom on new output unless the user scrolled up. - createEffect( - on( - () => debug.state.output.length, - () => { - if (!scroller || !atBottom) return - queueMicrotask(() => { - if (scroller) scroller.scrollTop = scroller.scrollHeight - }) - }, - ), - ) - - const onScroll = () => { - if (!scroller) return - atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 24 - } +const PANEL_META = PANEL_VIEW_META - return ( -
- 0} - fallback={ -
- {language.t("terminal.debugConsole.placeholder")} -
- } - > - - {(line) =>
{line.text}
} -
-
-
- ) -} - -// ─── Element size tracking (ratio ⇄ px conversion for split resize) ─────────── - -function createElementSize() { - const [size, setSize] = createSignal({ width: 0, height: 0 }) - let el: HTMLElement | undefined - let observer: ResizeObserver | undefined - - const measure = () => { - if (!el) return - setSize({ width: el.clientWidth, height: el.clientHeight }) - } - - const ref = (node: HTMLElement) => { - el = node - if (typeof ResizeObserver === "undefined") { - queueMicrotask(measure) - return - } - observer = new ResizeObserver(measure) - observer.observe(node) - queueMicrotask(measure) - } - - onCleanup(() => observer?.disconnect()) - - return { ref, size } +type Props = { + onOpenFile: (path: string, line: number) => void } -// ─── Split node ─────────────────────────────────────────────────────────────── - -function SplitPane(props: { node: Extract }) { - const terminal = useTerminal() - const { ref, size } = createElementSize() - - // dir "horizontal" ⇒ a horizontal divider ⇒ children stacked top/bottom. - // dir "vertical" ⇒ a vertical divider ⇒ children side by side. - const stacked = () => props.node.dir === "horizontal" - const total = () => (stacked() ? size().height : size().width) - const firstPx = () => Math.round(total() * props.node.sizes[0]) - - return ( -
-
- -
- { - const t = total() - if (t <= 0) return - const ratio = Math.min(0.9, Math.max(0.1, px / t)) - terminal.resizePane(props.node.id, [ratio, 1 - ratio]) - }} - class="shrink-0" - classList={{ - // Same token + weight as the panel borders (bottom bar's `border-t - // border-weak-base`, sidebar's edge): a solid border-weak-base line that - // reads clearly against the panes' bg-background-stronger. Brightens to - // border-base on hover for the drag affordance. - "cursor-col-resize w-px hover:w-0.5 bg-border-weak-base hover:bg-border-base": !stacked(), - "cursor-row-resize h-px hover:h-0.5 bg-border-weak-base hover:bg-border-base": stacked(), - }} - /> -
- -
-
- ) -} - -// ─── Leaf node ────────────────────────────────────────────────────────────── - -function LeafPane(props: { node: PaneLeaf }) { - const terminal = useTerminal() - const language = useLanguage() - const { view } = useSessionLayout() - const [store, setStore] = createStore({ - recovered: {} as Record, - }) - - const opened = createMemo(() => view().terminal.opened()) - const focused = createMemo(() => terminal.focusedPaneId() === props.node.id) - const ptys = createMemo(() => { - const owned = new Set(props.node.ptys) - // Preserve leaf order, hydrate from the authoritative pty list. - const byId = new Map(terminal.all().map((p) => [p.id, p] as const)) - return props.node.ptys.flatMap((id) => { - const p = byId.get(id) - return p && owned.has(id) ? [p] : [] - }) - }) - const activeId = createMemo(() => props.node.activeId) - - const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => { - if (store.recovered[key]) return - setStore("recovered", key, true) - void clone(id) - } - const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => - String(pty.titleNumber || pty.title || pty.id) - const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => { - setStore("recovered", key, false) - trim(id) - } - - const handleDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (!draggable || !droppable) return - const list = ptys() - const fromIndex = list.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = list.findIndex((t) => t.id === droppable.id.toString()) - if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - terminal.move(draggable.id.toString(), toIndex) - } - } - - const ids = createMemo(() => ptys().map((p) => p.id)) - - return ( -
terminal.setFocusedPane(props.node.id)} - > - - - - { - terminal.setFocusedPane(props.node.id) - terminal.activateInPane(props.node.id, id) - }} - class="!h-auto !flex-none" - > - {/* Per-pane header holds only its own tabs. Split/new actions live once - in the panel dock strip (VSCode-style) and target the focused pane. */} -
- - - {(pty) => } - - -
-
-
- } - > - {(id) => { - const ops = terminal.bind() - return ( - pty.id === id)}> - {(pty) => ( -
- markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} - onCleanup={ops.update} - onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} - /> -
- )} -
- ) - }} -
-
-
-
- ) -} - -function PaneRenderer(props: { node: PaneNode }): ReturnType { - return ( - ) : undefined} keyed - fallback={} - > - {(split) => } - - ) -} - -export function TerminalPanel() { +export function TerminalPanel(props: Props) { const layout = useLayout() const terminal = useTerminal() const language = useLanguage() - const command = useCommand() const { params, workspaceKey, view } = useSessionLayout() - - const opened = createMemo(() => view().terminal.opened()) const size = createSizing() const height = createMemo(() => layout.terminal.height()) - const close = () => view().terminal.close() - let root: HTMLDivElement | undefined - + const panel = () => view().panel + const opened = createMemo(() => panel().bottom.opened()) + const active = createMemo(() => panel().bottom.activeView()) + const bottomTabs = createMemo(() => panel().viewsAt("bottom")) + const visible = createMemo(() => opened()) const [store, setStore] = createStore({ - autoCreated: false, - dock: "terminal" as DockTabKind, view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), }) + let root: HTMLDivElement | undefined const max = () => store.view * 0.6 const pane = () => Math.min(height(), max()) + const close = () => panel().bottom.toggle() + const terminalVisible = createMemo(() => { + const current = panel() + return current.location("terminal") === "bottom" + ? current.bottom.opened() && current.bottom.activeView() === "terminal" + : view().rightPanel.mode() === "terminal" + }) + + // This is the only lifecycle owner. Exactly one visible host mounts the pane + // tree; moving the panel reconnects renderers to the same runtime PTYs. + useTerminalLifecycle({ + active: terminalVisible, + close: () => panel().toggle("terminal"), + rootEl: () => + document.querySelector('[data-terminal-host="bottom"], [data-terminal-host="side"]') ?? root, + }) onMount(() => { if (typeof window === "undefined") return const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight) - const port = window.visualViewport sync() makeEventListener(window, "resize", sync) - if (port) makeEventListener(port, "resize", sync) - }) - - createEffect(() => { - if (!opened()) { - setStore("autoCreated", false) - return - } - if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return - terminal.new() - setStore("autoCreated", true) - }) - - createEffect( - on( - () => terminal.all().length, - (count, prevCount) => { - if (prevCount === undefined || prevCount <= 0 || count !== 0) return - if (!opened()) return - close() - }, - ), - ) - - const focus = (id: string) => { - focusTerminalById(id) - const frame = requestAnimationFrame(() => { - if (!opened()) return - if (terminal.active() !== id) return - focusTerminalById(id) - }) - const timers = [120, 240].map((ms) => - window.setTimeout(() => { - if (!opened()) return - if (terminal.active() !== id) return - focusTerminalById(id) - }, ms), - ) - return () => { - cancelAnimationFrame(frame) - for (const timer of timers) clearTimeout(timer) - } - } - - createEffect( - on( - () => [opened(), terminal.active(), terminal.focusedPaneId()] as const, - ([next, id]) => { - if (!next || !id) return - if (store.dock !== "terminal") return - const stop = focus(id) - onCleanup(stop) - }, - ), - ) - - createEffect(() => { - if (opened()) return - const active = document.activeElement - if (!(active instanceof HTMLElement)) return - if (!root?.contains(active)) return - active.blur() + if (window.visualViewport) makeEventListener(window.visualViewport, "resize", sync) }) createEffect(() => { - const dir = params.dir - if (!dir) return - if (!terminal.ready()) return + if (!params.dir || !terminal.ready()) return language.locale() setTerminalHandoff( workspaceKey(), @@ -382,41 +81,30 @@ export function TerminalPanel() { ) }) - const handoff = createMemo(() => { - const dir = params.dir - if (!dir) return [] - return getTerminalHandoff(workspaceKey()) ?? [] - }) - - const dockTabs: { kind: DockTabKind; label: () => string }[] = [ - { kind: "terminal", label: () => language.t("terminal.dock.terminal") }, - { kind: "debug-console", label: () => language.t("terminal.dock.debugConsole") }, - ] - return (
- -
- - {(title) => ( -
- {title} -
- )} -
-
-
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
-
-
{language.t("terminal.loading")}
-
- } - > -
- {/* Dock strip: tabs on the left, a single set of terminal actions on the - right (VSCode-style). Split/new act on the focused pane, so they never - duplicate as panes split. */} -
- - {(tab) => ( +
+
+
+ + {(id) => ( )} -
- -
- - terminal.split("vertical")} - aria-label={language.t("terminal.split")} - /> - - -
-
- }> -
- -
+
+ + + + +
-
+
+ +
{language.t("session.panel.emptyBottom")}
+ panel().location(id) === "side", + )} + > + {(id) => ( + + )} + +
+ } + > + {(id) => ( + + + + {language.t("terminal.loading")} +
+ } + > +
+ +
+
+ + + + + + visible() && active() === "problems"} onOpenFile={props.onOpenFile} /> + + + )} + +
+
) diff --git a/packages/app/src/pages/session/terminal-view.tsx b/packages/app/src/pages/session/terminal-view.tsx new file mode 100644 index 00000000..e218c0d3 --- /dev/null +++ b/packages/app/src/pages/session/terminal-view.tsx @@ -0,0 +1,460 @@ +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { Tabs } from "@deepagent-code/ui/tabs" +import { ResizeHandle } from "@deepagent-code/ui/resize-handle" +import { IconButton } from "@deepagent-code/ui/icon-button" +import { Button } from "@deepagent-code/ui/button" +import { TooltipKeybind, Tooltip } from "@deepagent-code/ui/tooltip" +import { DragDropProvider, DragDropSensors, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis } from "@/utils/solid-dnd" + +import { SortableTerminalTab } from "@/components/session" +import { Terminal } from "@/components/terminal" +import { useCommand } from "@/context/command" +import { useDebug } from "@/context/debug" +import { useLanguage } from "@/context/language" +import { + useTerminal, + type LocalPTY, + type PaneLeaf, + type PaneNode, + MIN_TERMINAL_PANE_HEIGHT, + MIN_TERMINAL_PANE_WIDTH, +} from "@/context/terminal" +import { focusTerminalById } from "@/pages/session/helpers" + +// ─── Debug console (shared: bottom dock + side panel) ─────────────────────────── + +/** V3.7 Phase 4.5: Debug Console — renders the shared debug output stream. */ +export function DebugConsole() { + const debug = useDebug() + const language = useLanguage() + let scroller: HTMLDivElement | undefined + let atBottom = true + + const categoryColor = (c: string) => + c === "stderr" ? "text-red-400" : c === "console" ? "text-blue-400" : "text-text-base" + + // Auto-scroll to bottom on new output unless the user scrolled up. + createEffect( + on( + () => debug.state.output.length, + () => { + if (!scroller || !atBottom) return + queueMicrotask(() => { + if (scroller) scroller.scrollTop = scroller.scrollHeight + }) + }, + ), + ) + + const onScroll = () => { + if (!scroller) return + atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 24 + } + + return ( +
+ 0} + fallback={ +
+ {language.t("terminal.debugConsole.placeholder")} +
+ } + > + + {(line) =>
{line.text}
} +
+
+
+ ) +} + +// ─── Element size tracking (ratio ⇄ px conversion for split resize) ─────────── + +function createElementSize() { + const [size, setSize] = createSignal({ width: 0, height: 0 }) + let el: HTMLElement | undefined + let observer: ResizeObserver | undefined + + const measure = () => { + if (!el) return + setSize({ width: el.clientWidth, height: el.clientHeight }) + } + + const ref = (node: HTMLElement) => { + el = node + if (typeof ResizeObserver === "undefined") { + queueMicrotask(measure) + return + } + observer = new ResizeObserver(measure) + observer.observe(node) + queueMicrotask(measure) + } + + onCleanup(() => observer?.disconnect()) + + return { ref, size } +} + +// ─── Split node ───────────────────────────────────────────────────────────── + +function SplitPane(props: { node: Extract }) { + const terminal = useTerminal() + const { ref, size } = createElementSize() + + // dir "horizontal" ⇒ children side by side (left/right navigation). + // dir "vertical" ⇒ children stacked top/bottom (up/down navigation). + const stacked = () => props.node.dir === "vertical" + const total = () => (stacked() ? size().height : size().width) + const minimum = () => (stacked() ? MIN_TERMINAL_PANE_HEIGHT : MIN_TERMINAL_PANE_WIDTH) + const canResize = () => total() >= minimum() * 2 + const firstPx = () => Math.round(total() * props.node.sizes[0]) + const minPx = () => minimum() + const maxPx = () => Math.max(minPx(), total() - minimum()) + + return ( +
+
+ +
+ +
+ { + const t = total() + if (t <= 0) return + const ratio = Math.min(1 - minimum() / t, Math.max(minimum() / t, px / t)) + terminal.resizePane(props.node.id, [ratio, 1 - ratio]) + }} + class="absolute inset-0" + classList={{ + "cursor-col-resize bg-border-weak-base hover:bg-border-base": !stacked(), + "cursor-row-resize bg-border-weak-base hover:bg-border-base": stacked(), + }} + /> +
+
+
+ +
+
+ ) +} + +// ─── Leaf node ──────────────────────────────────────────────────────────────── + +function LeafPane(props: { node: PaneLeaf }) { + const terminal = useTerminal() + const focused = createMemo(() => terminal.focusedPaneId() === props.node.id) + let ref: HTMLDivElement | undefined + let observer: ResizeObserver | undefined + + const reportBounds = () => { + if (!ref) return + terminal.setPaneBounds(props.node.id, { width: ref.clientWidth, height: ref.clientHeight }) + } + + onMount(() => { + observer = new ResizeObserver(reportBounds) + if (ref) observer.observe(ref) + reportBounds() + }) + onCleanup(() => { + observer?.disconnect() + terminal.setPaneBounds(props.node.id, undefined) + }) + + const ptys = createMemo(() => { + const owned = new Set(props.node.ptys) + const byId = new Map(terminal.all().map((p) => [p.id, p] as const)) + return props.node.ptys.flatMap((id) => { + const p = byId.get(id) + return p && owned.has(id) ? [p] : [] + }) + }) + const activeId = createMemo(() => props.node.activeId) + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + const list = ptys() + const fromIndex = list.findIndex((t) => t.id === draggable.id.toString()) + const toIndex = list.findIndex((t) => t.id === droppable.id.toString()) + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + terminal.move(draggable.id.toString(), toIndex) + } + } + + const ids = createMemo(() => ptys().map((p) => p.id)) + + return ( +
terminal.setFocusedPane(props.node.id)} + onFocusIn={() => terminal.setFocusedPane(props.node.id)} + > + + + + +
+ + + {(pty) => } + + +
+
+
+ void terminal.new()} + /> + } + > + + {(pty) => ( + + + + )} + + +
+
+
+ ) +} + +function TerminalEmptyState(props: { creating: boolean; error?: { message: string }; onRetry: () => void }) { + const language = useLanguage() + return ( +
+
+ {props.error?.message ?? language.t(props.creating ? "terminal.status.starting" : "terminal.loading")} +
+ + + +
+ ) +} + +function TerminalSessionView(props: { pty: LocalPTY; focused: boolean }) { + const terminal = useTerminal() + const language = useLanguage() + const unavailable = () => props.pty.status === "error" || props.pty.status === "exited" + const status = () => + props.pty.status === "reconnecting" + ? language.t("terminal.status.reconnecting") + : language.t("terminal.status.connecting") + + return ( +
+ +
+ {props.pty.error?.message ?? language.t("terminal.status.exited")} +
+
+ + +
+
+ } + > + + {(ptyId) => ( + terminal.setStatus(props.pty.id, ptyId, next, error)} + /> + )} + + +
+ {status()} +
+
+ +
+ ) +} + +function PaneRenderer(props: { node: PaneNode }) { + const split = () => props.node.kind === "split" + return ( + }> + } /> + + ) +} + +// ─── Shared terminal actions (split / new) ────────────────────────────────────── + +/** The split + new-terminal action cluster shared by the bottom dock strip and the side panel + * header. Acts on the focused pane, so it never duplicates as panes split (VSCode-style). */ +export function TerminalActions() { + const terminal = useTerminal() + const language = useLanguage() + const command = useCommand() + return ( +
+ + terminal.split("horizontal")} + aria-label={language.t("terminal.split")} + /> + + + ) +} + +/** The pane tree is mounted only in the currently visible terminal host. */ +export function TerminalPanes() { + const terminal = useTerminal() + return ( +
+ +
+ ) +} + +// ─── Shared terminal lifecycle ────────────────────────────────────────────────── + +/** Auto-create-on-open, focus-follows-active, blur-on-hide, and close-on-empty — the effects that + * make a terminal surface behave, regardless of WHERE it's docked. `active()` = "the terminal is + * currently shown in this location"; `close()` = hide this location (close the bottom dock / close + * the side panel). Since a panel lives in exactly one location at a time, only one caller's + * `active()` is ever true, so there's no double auto-create or double close. */ +export function useTerminalLifecycle(opts: { + active: () => boolean + close: () => void + rootEl: () => HTMLElement | undefined +}) { + const terminal = useTerminal() + const [autoCreated, setAutoCreated] = createSignal(false) + + createEffect(() => { + if (!opts.active()) { + setAutoCreated(false) + return + } + if (terminal.all().length !== 0) { + setAutoCreated(false) + return + } + if (!terminal.ready() || terminal.creating() || terminal.createError() || autoCreated()) return + void terminal.new() + setAutoCreated(true) + }) + + createEffect( + on( + () => terminal.closeRequest(), + (request, previous) => { + if (previous === undefined || request === previous) return + if (!opts.active()) return + opts.close() + }, + ), + ) + + const focus = (id: string) => { + focusTerminalById(id) + const frame = requestAnimationFrame(() => { + if (!opts.active()) return + if (terminal.active() !== id) return + focusTerminalById(id) + }) + const timers = [120, 240].map((ms) => + window.setTimeout(() => { + if (!opts.active()) return + if (terminal.active() !== id) return + focusTerminalById(id) + }, ms), + ) + return () => { + cancelAnimationFrame(frame) + for (const timer of timers) clearTimeout(timer) + } + } + + createEffect( + on( + () => [opts.active(), terminal.active(), terminal.focusedPaneId()] as const, + ([next, id]) => { + if (!next || !id) return + const stop = focus(id) + onCleanup(stop) + }, + ), + ) + + createEffect(() => { + if (opts.active()) return + const el = document.activeElement + if (!(el instanceof HTMLElement)) return + if (!opts.rootEl()?.contains(el)) return + el.blur() + }) +} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 8ade97d3..6039179f 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -260,8 +260,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const openTerminal = () => { - if (terminal.all().length > 0) terminal.new() - view().terminal.open() + terminal.new() + view().panel.reveal("terminal") } const chooseModel = () => { @@ -495,14 +495,51 @@ export const useSessionCommands = (actions: SessionCommandContext) => { view().rightPanel.open("review") }, }), + viewCommand({ + id: "panel.toggle", + title: language.t("command.panel.toggle"), + keybind: "mod+j", + onSelect: () => view().panel.bottom.toggle(), + }), viewCommand({ id: "terminal.toggle", title: language.t("command.terminal.toggle"), keybind: "ctrl+`", slash: "terminal", - onSelect: () => { - view().terminal.toggle() - }, + onSelect: () => view().panel.toggle("terminal"), + }), + viewCommand({ + id: "debugConsole.reveal", + title: language.t("command.debugConsole.reveal"), + onSelect: () => view().panel.reveal("debug-console"), + }), + viewCommand({ + id: "problems.reveal", + title: language.t("command.problems.reveal"), + onSelect: () => view().panel.reveal("problems"), + }), + ...(["terminal", "debug-console", "problems"] as const).flatMap((id) => { + const label = language.t(`session.panel.${id === "debug-console" ? "debugConsole" : id}`) + const location = view().panel.location(id) + return [ + viewCommand({ + id: `panel.${id}.show`, + title: `${language.t("session.panel.showView")}: ${label}`, + onSelect: () => view().panel.reveal(id), + }), + viewCommand({ + id: `panel.${id}.moveToSide`, + title: `${language.t("session.panel.moveToSide")}: ${label}`, + disabled: location !== "bottom" || !view().panel.sideAvailable(), + onSelect: () => view().panel.move(id, "side"), + }), + viewCommand({ + id: `panel.${id}.moveToBottom`, + title: `${language.t("session.panel.moveToBottom")}: ${label}`, + disabled: location !== "side", + onSelect: () => view().panel.move(id, "bottom"), + }), + ] }), viewCommand({ id: "review.toggle", @@ -532,7 +569,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ] - const terminalOpen = () => view().terminal.opened() + const terminalOpen = () => { + const panel = view().panel + return panel.location("terminal") === "bottom" + ? panel.bottom.opened() && panel.bottom.activeView() === "terminal" + : view().rightPanel.mode() === "terminal" + } const focusedPane = () => terminal.focusedPaneId() const terminalCmds = () => [ terminalCommand({ @@ -547,14 +589,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { // terminal.new's ctrl+alt+t) to avoid colliding with fileTree.toggle (mod+\) // and tab.close (mod+w) — mod resolves to ctrl on non-Mac, so ctrl+\/ctrl+w // were being preempted by the earlier-registered view/file commands. - // Only a left/right (side-by-side) split is exposed; "vertical" here means a - // vertical divider ⇒ panes arranged left and right. + // Only a left/right (side-by-side) split is exposed. Keep the command and + // toolbar on the same direction so both entry points have identical layout. id: "terminal.split", title: language.t("command.terminal.split"), description: language.t("command.terminal.split.description"), keybind: "ctrl+alt+\\", disabled: !terminalOpen() || !terminal.canSplit(focusedPane()), - onSelect: () => terminal.split("vertical"), + onSelect: () => terminal.split("horizontal"), }), terminalCommand({ id: "terminal.closePane", diff --git a/packages/app/src/utils/agent.ts b/packages/app/src/utils/agent.ts index c3d1aafd..59da53af 100644 --- a/packages/app/src/utils/agent.ts +++ b/packages/app/src/utils/agent.ts @@ -1,12 +1,8 @@ const defaults: Record = { ask: "var(--icon-agent-ask-base)", - // auto is the renamed default mode (was "build"); keep "build" mapped for older sessions. - auto: "var(--icon-agent-build-base)", build: "var(--icon-agent-build-base)", docs: "var(--icon-agent-docs-base)", plan: "var(--icon-agent-plan-base)", - loop: "var(--syntax-info)", - design: "var(--syntax-property)", } const palette = [ diff --git a/packages/app/src/utils/im-client.ts b/packages/app/src/utils/im-client.ts index 8b212e08..93c6decf 100644 --- a/packages/app/src/utils/im-client.ts +++ b/packages/app/src/utils/im-client.ts @@ -1,7 +1,7 @@ import { useSDK } from "@/context/sdk" import { useServer } from "@/context/server" import { authTokenFromCredentials } from "@/utils/server" -import type { AgentDescriptor, IMAttachment, IMGroup, IMMessage } from "@/components/im/types" +import type { AgentDescriptor, IMGroup, IMMessage } from "@/components/im/types" export interface IMClientConfig { /** Server base URL (e.g. http://127.0.0.1:PORT, or /w in server mode). */ @@ -82,37 +82,10 @@ export function createIMClient(config: () => IMClientConfig) { return (await response.json()) as T } - // Multipart request for file uploads — the JSON `request` helper can't carry a - // FormData body. Reuses the same auth + directory/workspace routing headers, - // but lets the browser set the multipart Content-Type (with its boundary). - const uploadRequest = async ( - path: string, - form: FormData, - query?: Record, - ): Promise => { - const c = config() - const headers: Record = { ...authHeaders(c) } - if (c.directory) headers["x-deepagent-code-directory"] = c.directory - if (c.workspace) headers["x-deepagent-code-workspace"] = c.workspace - const response = await fetch(buildURL(path, query), { method: "POST", headers, body: form }) - if (!response.ok) { - const text = await response.text().catch(() => "") - throw new Error(`IM upload failed (${response.status}): ${text || response.statusText}`) - } - return (await response.json()) as T - } - return { listGroups: () => request(`/api/v1/im/groups`), - // §B3: `direct` groups carry a `member` (the counterparty) alongside the - // server-user creator. `member` is required by the backend when type === - // "direct" and ignored otherwise. - createGroup: (payload: { - name: string - type: "project" | "system" | "direct" - projectID?: string - member?: { memberID: string; memberType: "user" | "agent" } - }) => request(`/api/v1/im/groups`, { method: "POST", body: payload }), + createGroup: (payload: { name: string; type: "project" | "system"; projectID?: string }) => + request(`/api/v1/im/groups`, { method: "POST", body: payload }), listMessages: (groupID: string, limit = 50, cursor?: string) => request(`/api/v1/im/groups/${groupID}/messages`, { query: { limit: String(limit), cursor }, @@ -131,49 +104,6 @@ export function createIMClient(config: () => IMClientConfig) { body: { readAt }, }), listAgents: () => request(`/api/v1/im/agents`), - // §B3 Thread — the replies to a parent message, keyset paginated (ASC chronological). - listThread: (groupID: string, messageID: string, limit = 50, cursor?: string) => - request(`/api/v1/im/groups/${groupID}/messages/${messageID}/thread`, { - query: { limit: String(limit), cursor }, - }), - // §B3 搜索 — full-text + metadata search across the caller's group memberships. - searchMessages: (params: { - q: string - groupId?: string - senderType?: "user" | "agent" | "system" - type?: "text" | "code" | "file" | "agent_status" | "system" - metadataType?: string - limit?: number - cursor?: string - }) => - request(`/api/v1/im/search`, { - query: { - q: params.q, - groupId: params.groupId, - senderType: params.senderType, - type: params.type, - metadataType: params.metadataType, - limit: params.limit !== undefined ? String(params.limit) : undefined, - cursor: params.cursor, - }, - }), - // §B3 文件 — upload a file (multipart). `groupId`/`messageId` optionally scope it to a message. - uploadAttachment: (file: File, opts?: { groupId?: string; messageId?: string }) => { - const form = new FormData() - form.append("file", file, file.name) - if (opts?.groupId) form.append("groupId", opts.groupId) - if (opts?.messageId) form.append("messageId", opts.messageId) - return uploadRequest(`/api/v1/im/attachments`, form) - }, - // §B3 文件 — list attachment records for a group / message / the workspace. - listAttachments: (opts?: { groupId?: string; messageId?: string; limit?: number }) => - request(`/api/v1/im/attachments`, { - query: { - groupId: opts?.groupId, - messageId: opts?.messageId, - limit: opts?.limit !== undefined ? String(opts.limit) : undefined, - }, - }), // Server Edition only: set the `access_token` cookie the gateway reads to // authenticate the WebSocket upgrade (browsers can't set WS headers). No-op // for self-hosted (which uses the `auth_token` query instead) or outside a diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 0d828f43..897aa724 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -129,6 +129,8 @@ describe("persist localStorage resilience", () => { persistTesting.workspaceStorage("C:\\Users\\foo"), persistTesting.legacyAppWorkspaceStorage("C:/Users/foo"), persistTesting.legacyAppWorkspaceStorage("C:\\Users\\foo"), + persistTesting.legacyEncodedWorkspaceStorage("C:/Users/foo"), + persistTesting.legacyEncodedWorkspaceStorage("C:\\Users\\foo"), ]), ) }) @@ -142,6 +144,8 @@ describe("persist localStorage resilience", () => { persistTesting.workspaceStorage("C:\\Users\\foo"), persistTesting.legacyAppWorkspaceStorage("C:/Users/foo"), persistTesting.legacyAppWorkspaceStorage("C:\\Users\\foo"), + persistTesting.legacyEncodedWorkspaceStorage("C:/Users/foo"), + persistTesting.legacyEncodedWorkspaceStorage("C:\\Users\\foo"), ]), ) }) @@ -169,13 +173,16 @@ describe("persist localStorage resilience", () => { test("removes legacy workspace storage when removing persisted target", () => { const target = Persist.workspace("C:\\Users\\foo", "terminal") + const encoded = persistTesting.legacyEncodedWorkspaceStorage("C:\\Users\\foo") storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}') + storage.setItem(`${encoded}:${target.key}`, '{"value":3}') removePersisted(target) expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() + expect(storage.getItem(`${encoded}:${target.key}`)).toBeNull() }) test("server workspace target preserves local storage and isolates remote storage", () => { diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index f866580e..c7da5309 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,6 +1,6 @@ import { Platform, usePlatform } from "@/context/platform" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" -import { checksum } from "@deepagent-code/core/util/encode" +import { base64Encode, checksum } from "@deepagent-code/core/util/encode" import { createResource, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" import { pathKey } from "@/utils/path-key" @@ -351,6 +351,12 @@ function legacyAppWorkspaceStorage(dir: string) { return `${LEGACY_APP_STORAGE}.workspace.${head}.${sum}.dat` } +function legacyEncodedWorkspaceStorage(dir: string) { + const head = base64Encode(dir).slice(0, 12) || "workspace" + const sum = checksum(dir) ?? "0" + return `${APP_STORAGE}.workspace.${head}.${sum}.dat` +} + function legacyWorkspaceStorage(dir: string) { const storage = workspaceStorage(pathKey(dir)) const result = new Set() @@ -358,6 +364,8 @@ function legacyWorkspaceStorage(dir: string) { if (raw !== storage) result.add(raw) result.add(legacyAppWorkspaceStorage(pathKey(dir))) result.add(legacyAppWorkspaceStorage(dir)) + result.add(legacyEncodedWorkspaceStorage(pathKey(dir))) + result.add(legacyEncodedWorkspaceStorage(dir)) const key = pathKey(dir) const drive = key.length >= 3 && key[1] === ":" && key[2] === "/" @@ -365,6 +373,7 @@ function legacyWorkspaceStorage(dir: string) { const backslash = workspaceStorage(key.replaceAll("/", "\\")) if (backslash !== storage) result.add(backslash) result.add(legacyAppWorkspaceStorage(key.replaceAll("/", "\\"))) + result.add(legacyEncodedWorkspaceStorage(key.replaceAll("/", "\\"))) } result.delete(storage) @@ -472,6 +481,7 @@ export const PersistTesting = { normalize, workspaceStorage, legacyAppWorkspaceStorage, + legacyEncodedWorkspaceStorage, } export const Persist = { diff --git a/packages/cli/package.json b/packages/cli/package.json index 05b70c77..7d32d1b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,15 +17,20 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@effect/platform-node": "catalog:", + "@agentclientprotocol/sdk": "^0.21.0", "@deepagent-code/core": "workspace:*", "@deepagent-code/sdk": "workspace:*", "@deepagent-code/server": "workspace:*", "@deepagent-code/tui": "workspace:*", + "@effect/platform-node": "catalog:", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", + "deepagent-code": "workspace:*", + "drizzle-orm": "catalog:", "effect": "catalog:", + "jsonc-parser": "^3.3.1", "solid-js": "catalog:" }, "devDependencies": { diff --git a/packages/cli/src/ambient.d.ts b/packages/cli/src/ambient.d.ts new file mode 100644 index 00000000..1df3b0aa --- /dev/null +++ b/packages/cli/src/ambient.d.ts @@ -0,0 +1,8 @@ +// Ambient declarations for asset modules referenced transitively through the +// deepagent-code source graph (cli imports `deepagent-code/server/server`, +// which pulls in handlers that import image/audio assets). These mirror the +// declarations in packages/deepagent-code/src/audio.d.ts. +declare module "*.wasm" { + const file: string + export default file +} diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 3f70742c..997bbe4f 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -7,12 +7,227 @@ export const Commands = Spec.make( typeof DEEPAGENT_CODE_CLI_NAME === "string" ? DEEPAGENT_CODE_CLI_NAME : "deepagent-code", { description: "DeepAgent Code 2.0 preview command line interface", + params: { + project: Argument.string("project").pipe(Argument.optional), + continue: Flag.boolean("continue").pipe(Flag.withAlias("c"), Flag.withDefault(false)), + session: Flag.string("session").pipe(Flag.withAlias("s"), Flag.optional), + fork: Flag.boolean("fork").pipe(Flag.withDefault(false)), + model: Flag.string("model").pipe(Flag.withAlias("m"), Flag.optional), + agent: Flag.string("agent").pipe(Flag.optional), + prompt: Flag.string("prompt").pipe(Flag.optional), + }, commands: [ Spec.make("debug", { description: "Debugging and troubleshooting tools", commands: [Spec.make("agents", { description: "List all agents" })], }), Spec.make("migrate", { description: "Migrate v1 data to v2" }), + Spec.make("models", { + description: "List all available models", + params: { + provider: Argument.string("provider").pipe(Argument.optional), + verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)), + }, + }), + Spec.make("run", { + description: "Run deepagent-code with a message", + params: { + message: Argument.string("message").pipe(Argument.variadic), + model: Flag.string("model").pipe(Flag.withAlias("m"), Flag.optional), + agent: Flag.string("agent").pipe(Flag.optional), + format: Flag.string("format").pipe(Flag.withDefault("default")), + continue: Flag.boolean("continue").pipe(Flag.withAlias("c"), Flag.withDefault(false)), + session: Flag.string("session").pipe(Flag.withAlias("s"), Flag.optional), + fork: Flag.boolean("fork").pipe(Flag.withDefault(false)), + variant: Flag.string("variant").pipe(Flag.optional), + "dangerously-skip-permissions": Flag.boolean("dangerously-skip-permissions").pipe(Flag.withDefault(false)), + }, + }), + Spec.make("export", { + description: "Export session data as JSON", + params: { + sessionID: Argument.string("sessionID").pipe(Argument.optional), + sanitize: Flag.boolean("sanitize").pipe(Flag.withDefault(false)), + }, + }), + Spec.make("stats", { + description: "Show token usage and cost statistics", + params: { + days: Flag.integer("days").pipe(Flag.optional), + format: Flag.string("format").pipe(Flag.withDefault("table")), + }, + }), + Spec.make("import", { + description: "Import session data from Codex or Claude export", + params: { + file: Argument.string("file"), + source: Flag.string("source").pipe(Flag.withDefault("codex")), + }, + }), + Spec.make("session", { + description: "Manage sessions", + commands: [ + Spec.make("list", { + description: "List sessions", + params: { + "max-count": Flag.integer("max-count").pipe(Flag.optional), + format: Flag.string("format").pipe(Flag.withDefault("table")), + }, + }), + Spec.make("delete", { + description: "Delete a session", + params: { sessionID: Argument.string("sessionID") }, + }), + ], + }), + Spec.make("auth", { + description: "Manage AI provider credentials", + commands: [ + Spec.make("login", { + description: "Log in to a provider", + params: { + provider: Argument.string("provider").pipe(Argument.optional), + key: Flag.string("key").pipe(Flag.optional), + }, + }), + Spec.make("list", { description: "List configured credentials" }), + Spec.make("logout", { + description: "Log out from a provider", + params: { provider: Argument.string("provider").pipe(Argument.optional) }, + }), + ], + }), + Spec.make("agent", { + description: "Manage agents", + commands: [Spec.make("list", { description: "List all available agents" })], + }), + Spec.make("mcp", { + description: "Manage MCP servers", + commands: [ + Spec.make("list", { description: "List MCP servers and their status" }), + Spec.make("add", { + description: "Add an MCP server", + params: { + name: Argument.string("name"), + url: Flag.string("url").pipe(Flag.optional), + command: Flag.string("command").pipe(Flag.optional), + env: Flag.string("env").pipe(Flag.optional), + header: Flag.string("header").pipe(Flag.optional), + }, + }), + ], + }), + Spec.make("packs", { + description: "Manage domain packs", + params: { + action: Argument.choice("action", ["list", "pin", "unpin"]), + packId: Argument.string("packId").pipe(Argument.optional), + }, + }), + Spec.make("wiki", { + description: "Search and browse the project Wiki", + params: { + action: Argument.choice("action", ["list", "get", "search"]), + args: Argument.string("args").pipe(Argument.variadic), + type: Flag.string("type").pipe(Flag.optional), + scope: Flag.string("scope").pipe(Flag.optional), + }, + }), + Spec.make("review", { + description: "Review pending DeepAgent knowledge", + params: { + action: Argument.choice("action", ["pending", "approve", "reject"]), + ids: Argument.string("ids").pipe(Argument.variadic), + }, + }), + Spec.make("env-facts", { + description: "Manage environment facts", + params: { + action: Argument.choice("action", ["list", "decide"]), + factId: Argument.string("factId").pipe(Argument.optional), + decision: Argument.choice("decision", ["adopt", "reject"]).pipe(Argument.optional), + }, + }), + Spec.make("goal", { + description: "Manage Goal Loop for a session", + params: { + action: Argument.choice("action", ["start", "status", "pause", "resume", "stop"]), + sessionID: Argument.string("sessionID"), + objective: Flag.string("objective").pipe(Flag.optional), + }, + }), + Spec.make("panel", { + description: "Expert Panel status", + params: { + action: Argument.choice("action", ["status"]), + sessionID: Argument.string("sessionID"), + }, + }), + Spec.make("attach", { + description: "Attach to a running deepagent-code server", + params: { + url: Argument.string("url"), + dir: Flag.string("dir").pipe(Flag.optional), + continue: Flag.boolean("continue").pipe(Flag.withAlias("c"), Flag.withDefault(false)), + session: Flag.string("session").pipe(Flag.withAlias("s"), Flag.optional), + fork: Flag.boolean("fork").pipe(Flag.withDefault(false)), + password: Flag.string("password").pipe(Flag.withAlias("p"), Flag.optional), + username: Flag.string("username").pipe(Flag.withAlias("u"), Flag.optional), + }, + }), + Spec.make("db", { + description: "Database tools", + params: { + query: Argument.string("query").pipe(Argument.optional), + format: Flag.string("format").pipe(Flag.withDefault("tsv")), + }, + commands: [Spec.make("path", { description: "Print the database path" })], + }), + Spec.make("web", { + description: "Start server and open web interface", + params: { + hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), + port: Flag.integer("port").pipe(Flag.optional), + mdns: Flag.boolean("mdns").pipe(Flag.withDefault(false)), + "mdns-domain": Flag.string("mdns-domain").pipe(Flag.withDefault("deepagent-code.local")), + cors: Flag.string("cors").pipe(Flag.optional), + }, + }), + Spec.make("upgrade", { + description: "Upgrade deepagent-code to the latest or a specific version", + params: { + target: Argument.string("target").pipe(Argument.optional), + method: Flag.string("method").pipe(Flag.withAlias("m"), Flag.optional), + }, + }), + Spec.make("uninstall", { + description: "Uninstall deepagent-code and remove all related files", + params: { + "keep-config": Flag.boolean("keep-config").pipe(Flag.withAlias("c"), Flag.withDefault(false)), + "keep-data": Flag.boolean("keep-data").pipe(Flag.withAlias("d"), Flag.withDefault(false)), + "dry-run": Flag.boolean("dry-run").pipe(Flag.withDefault(false)), + force: Flag.boolean("force").pipe(Flag.withAlias("f"), Flag.withDefault(false)), + }, + }), + Spec.make("pr", { + description: "Fetch and checkout a GitHub PR branch, then run deepagent-code", + params: { number: Argument.integer("number") }, + }), + Spec.make("acp", { + description: "Start ACP (Agent Client Protocol) server", + params: { + hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), + port: Flag.integer("port").pipe(Flag.optional), + }, + }), + Spec.make("github", { + description: "Manage GitHub agent", + params: { + action: Argument.choice("action", ["install", "run"]), + event: Flag.string("event").pipe(Flag.optional), + token: Flag.string("token").pipe(Flag.optional), + }, + }), Spec.make("service", { description: "Manage the background server", commands: [ @@ -27,11 +242,15 @@ export const Commands = Spec.make( ], }), Spec.make("serve", { - description: "Start the v2 API server", + description: "Start the deepagent-code server", params: { hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), port: Flag.integer("port").pipe(Flag.optional), register: Flag.boolean("register").pipe(Flag.withDefault(false)), + mdns: Flag.boolean("mdns").pipe(Flag.withDefault(false)), + "mdns-domain": Flag.string("mdns-domain").pipe(Flag.withDefault("deepagent-code.local")), + // Comma-separated additional CORS origins, e.g. --cors "http://a:1,http://b:2". + cors: Flag.string("cors").pipe(Flag.optional), }, }), ], diff --git a/packages/cli/src/commands/handlers/acp.ts b/packages/cli/src/commands/handlers/acp.ts new file mode 100644 index 00000000..e77f391b --- /dev/null +++ b/packages/cli/src/commands/handlers/acp.ts @@ -0,0 +1,55 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect, Option } from "effect" +import { ServerAuth } from "@deepagent-code/server/auth" + +export default Runtime.handler(Commands.commands.acp, (input) => + Effect.gen(function* () { + const { Server } = yield* Effect.promise(() => import("deepagent-code/server/server")) + const { ACP } = yield* Effect.promise(() => import("deepagent-code/acp/agent")) + process.env.DEEPAGENT_CODE_CLIENT = "acp" + + const port = Option.getOrElse(input.port as Option.Option, () => 0) + + const server = yield* Effect.promise(() => Server.listen({ hostname: input.hostname, port })) + + const { createOpencodeClient } = yield* Effect.promise(() => import("@deepagent-code/sdk/v2/client")) + const sdk = createOpencodeClient({ + baseUrl: `http://${server.hostname}:${server.port}`, + headers: ServerAuth.headers(), + }) + + const { AgentSideConnection, ndJsonStream } = yield* Effect.promise(() => + import("@agentclientprotocol/sdk"), + ) + + const acpInput = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + process.stdout.write(chunk, (err) => (err ? reject(err) : resolve())) + }) + }, + }) + const acpOutput = new ReadableStream({ + start(controller) { + process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))) + process.stdin.on("end", () => controller.close()) + process.stdin.on("error", (err) => controller.error(err)) + }, + }) + + const stream = ndJsonStream(acpInput, acpOutput) + const agent = ACP.init({ sdk }) + + new AgentSideConnection((conn: unknown) => agent.create(conn as never), stream) + process.stdin.resume() + + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + process.stdin.on("end", () => resolve()) + process.stdin.on("error", reject) + }), + ) + }), +) diff --git a/packages/cli/src/commands/handlers/agent/list.ts b/packages/cli/src/commands/handlers/agent/list.ts new file mode 100644 index 00000000..c006b315 --- /dev/null +++ b/packages/cli/src/commands/handlers/agent/list.ts @@ -0,0 +1,25 @@ +import { EOL } from "os" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.agent.commands.list, + Effect.fn("cli.agent.list")(function* () { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const result = yield* Effect.tryPromise(() => client.app.agents()) + const agents = result.data ?? [] + + const sorted = agents.sort((a, b) => { + if (a.native !== b.native) return a.native ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + for (const agent of sorted) { + process.stdout.write(`${agent.name} (${agent.mode})${agent.native ? " [native]" : ""}${EOL}`) + if (agent.description) process.stdout.write(` ${agent.description}${EOL}`) + } + }), +) diff --git a/packages/cli/src/commands/handlers/attach.ts b/packages/cli/src/commands/handlers/attach.ts new file mode 100644 index 00000000..0cbe3cb1 --- /dev/null +++ b/packages/cli/src/commands/handlers/attach.ts @@ -0,0 +1,48 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect, Option } from "effect" +import { ServerAuth } from "@deepagent-code/server/auth" + +export default Runtime.handler(Commands.commands.attach, (input) => + Effect.gen(function* () { + if (input.fork && !input.continue && Option.isNone(input.session as Option.Option)) { + yield* Effect.fail(new Error("--fork requires --continue or --session")) + } + + const dirOpt = input.dir as Option.Option + const directory = (() => { + if (Option.isNone(dirOpt)) return undefined + try { + process.chdir(dirOpt.value) + return process.cwd() + } catch { + return dirOpt.value + } + })() + + const password = Option.getOrElse(input.password as Option.Option, () => undefined) + const username = Option.getOrElse(input.username as Option.Option, () => undefined) + const headers = ServerAuth.headers({ password, username }) + + const sessionOpt = input.session as Option.Option + if (Option.isSome(sessionOpt)) { + const { createOpencodeClient } = yield* Effect.promise(() => import("@deepagent-code/sdk/v2/client")) + const client = createOpencodeClient({ baseUrl: input.url, directory, headers }) + yield* Effect.tryPromise({ + try: () => client.session.get({ sessionID: sessionOpt.value }, { throwOnError: true }), + catch: () => new Error(`Session not found: ${sessionOpt.value}`), + }) + } + + const { runTui } = yield* Effect.promise(() => import("../../tui")) + yield* runTui( + { url: input.url, headers }, + { + continue: input.continue, + sessionID: Option.getOrElse(sessionOpt, () => undefined), + fork: input.fork, + }, + directory, + ) + }), +) diff --git a/packages/cli/src/commands/handlers/auth/list.ts b/packages/cli/src/commands/handlers/auth/list.ts new file mode 100644 index 00000000..6109c644 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/list.ts @@ -0,0 +1,45 @@ +import { Option } from "effect" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.auth.commands.list, + Effect.fn("cli.auth.list")(function* () { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const result = yield* Effect.tryPromise(() => client.provider.list()) + + const providers = result.data?.all ?? [] + const connected = result.data?.connected ?? [] + + if (connected.length === 0) { + console.log("No credentials configured.") + console.log("Run `dacode auth login ` to add credentials.") + return + } + + const names = new Map(providers.map((p) => [p.id, p.name])) + for (const providerID of connected) { + const name = names.get(providerID) ?? providerID + console.log(`${name} (${providerID})`) + } + + console.log(`\n${connected.length} credential(s)`) + + const envVars: Array<{ provider: string; envVar: string }> = [] + for (const provider of providers) { + for (const envVar of provider.env) { + if (process.env[envVar]) envVars.push({ provider: provider.name ?? provider.id, envVar }) + } + } + + if (envVars.length > 0) { + console.log("\nEnvironment variables:") + for (const { provider, envVar } of envVars) { + console.log(` ${provider}: ${envVar}`) + } + } + }), +) diff --git a/packages/cli/src/commands/handlers/auth/login.ts b/packages/cli/src/commands/handlers/auth/login.ts new file mode 100644 index 00000000..598413ad --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/login.ts @@ -0,0 +1,82 @@ +import { Option } from "effect" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.auth.commands.login, + Effect.fn("cli.auth.login")(function* (input) { + const provider = Option.getOrNull(input.provider) + if (!provider) { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const result = yield* Effect.tryPromise(() => client.provider.list()) + const providers = result.data?.all ?? [] + if (providers.length === 0) { + return yield* Effect.fail(new Error("No providers available. Start the daemon first.")) + } + console.log("Available providers:") + for (const p of providers.sort((a, b) => a.name.localeCompare(b.name))) { + console.log(` ${p.id} — ${p.name}`) + } + console.log('\nUsage: dacode auth login --key ') + return + } + + // Well-known URL flow: provider starts with http:// or https:// + if (provider.startsWith("http://") || provider.startsWith("https://")) { + const url = provider.replace(/\/+$/, "") + const wellknown = yield* Effect.tryPromise(() => + fetch(`${url}/.well-known/deepagent-code`).then( + (r) => r.json() as Promise<{ auth: { command: string[]; env: string } }>, + ), + ) + + console.log(`Running: ${wellknown.auth.command.join(" ")}`) + const { spawn } = yield* Effect.promise(() => import("node:child_process")) + const proc = spawn(wellknown.auth.command[0], wellknown.auth.command.slice(1), { + stdio: ["inherit", "pipe", "inherit"], + }) + + const token = yield* Effect.promise(() => { + return new Promise((resolve, reject) => { + let output = "" + proc.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString())) + proc.on("close", (exit) => { + if (exit !== 0) reject(new Error("Auth provider command failed")) + else resolve(output.trim()) + }) + proc.on("error", reject) + }) + }) + + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + yield* Effect.tryPromise(() => + client.auth.set({ providerID: url, auth: { type: "wellknown", key: wellknown.auth.env, token } }), + ) + console.log(`Logged into ${url}`) + return + } + + // API key flow + const keyFlag = Option.getOrNull(input.key) + let apiKey = keyFlag + if (!apiKey && !process.stdin.isTTY) { + apiKey = (yield* Effect.promise(() => Bun.stdin.text())).trim() + } + if (!apiKey) { + return yield* Effect.fail( + new Error("API key required. Use --key or pipe via stdin: echo 'key' | dacode auth login "), + ) + } + + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + yield* Effect.tryPromise(() => + client.auth.set({ providerID: provider, auth: { type: "api", key: apiKey } }), + ) + console.log(`Credentials saved for ${provider}`) + }), +) diff --git a/packages/cli/src/commands/handlers/auth/logout.ts b/packages/cli/src/commands/handlers/auth/logout.ts new file mode 100644 index 00000000..294dcebf --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/logout.ts @@ -0,0 +1,30 @@ +import { Option } from "effect" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.auth.commands.logout, + Effect.fn("cli.auth.logout")(function* (input) { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + + const provider = Option.getOrNull(input.provider) + if (!provider) { + const result = yield* Effect.tryPromise(() => client.provider.list()) + const connected = result.data?.connected ?? [] + if (connected.length === 0) { + console.log("No credentials configured.") + return + } + console.log("Configured providers:") + for (const id of connected) console.log(` ${id}`) + console.log('\nUsage: dacode auth logout ') + return + } + + yield* Effect.tryPromise(() => client.auth.remove({ providerID: provider })) + console.log(`Logged out from ${provider}`) + }), +) diff --git a/packages/cli/src/commands/handlers/db.ts b/packages/cli/src/commands/handlers/db.ts new file mode 100644 index 00000000..59e5d0b5 --- /dev/null +++ b/packages/cli/src/commands/handlers/db.ts @@ -0,0 +1,33 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect, Option } from "effect" +import { sql } from "drizzle-orm" + +export default Runtime.handler(Commands.commands.db, (input) => + Effect.gen(function* () { + const queryOpt = input.query as Option.Option + const query = Option.getOrElse(queryOpt, () => undefined) + + if (query) { + const { Database } = yield* Effect.promise(() => import("@deepagent-code/core/database/database")) + const result = yield* Effect.gen(function* () { + const { db } = yield* Database.Service + return yield* db.all>(sql.raw(query)) + }).pipe(Effect.provide(Database.defaultLayer), Effect.orDie) + + if (input.format === "json") { + console.log(JSON.stringify(result, null, 2)) + } else if (result.length > 0) { + const keys = Object.keys(result[0]) + console.log(keys.join("\t")) + for (const row of result) console.log(keys.map((key) => row[key]).join("\t")) + } + return + } + + const { spawn } = yield* Effect.promise(() => import("node:child_process")) + const { Database } = yield* Effect.promise(() => import("@deepagent-code/core/database/database")) + const child = spawn("sqlite3", [Database.path()], { stdio: "inherit" }) + yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve))) + }), +) diff --git a/packages/cli/src/commands/handlers/db/path.ts b/packages/cli/src/commands/handlers/db/path.ts new file mode 100644 index 00000000..078d502e --- /dev/null +++ b/packages/cli/src/commands/handlers/db/path.ts @@ -0,0 +1,10 @@ +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Effect } from "effect" + +export default Runtime.handler(Commands.commands.db.commands.path, () => + Effect.gen(function* () { + const { Database } = yield* Effect.promise(() => import("@deepagent-code/core/database/database")) + console.log(Database.path()) + }), +) diff --git a/packages/cli/src/commands/handlers/deepagent/env-facts.ts b/packages/cli/src/commands/handlers/deepagent/env-facts.ts new file mode 100644 index 00000000..b9de4aac --- /dev/null +++ b/packages/cli/src/commands/handlers/deepagent/env-facts.ts @@ -0,0 +1,49 @@ +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { call } from "./util" + +type EnvFactItem = { + fact_id: string + version: number + description: string + degraded: boolean + body?: { host?: string; port?: number; container?: string; purpose?: string } +} + +export default Runtime.handler(Commands.commands["env-facts"], Effect.fn("cli.env-facts")(function* (input) { + if (input.action === "list") { + const result = yield* call((c) => c.deepagent.envFacts.list()) + const data = result.data as { adopted?: EnvFactItem[]; pending?: EnvFactItem[] } | undefined + const adopted = data?.adopted ?? [] + const pending = data?.pending ?? [] + + if (adopted.length > 0) { + console.log("Adopted:") + for (const f of adopted) { + const deg = f.degraded ? " (degraded)" : "" + console.log(` ${f.fact_id} v${f.version}${deg}: ${f.description}`) + } + } + if (pending.length > 0) { + if (adopted.length > 0) console.log() + console.log("Pending:") + for (const f of pending) { + const deg = f.degraded ? " (degraded)" : "" + console.log(` ${f.fact_id} v${f.version}${deg}: ${f.description}`) + } + } + if (adopted.length === 0 && pending.length === 0) console.log("No environment facts found") + return + } + + // decide + const factId = Option.getOrElse(input.factId, () => "") + const decision = Option.getOrElse(input.decision, () => "") + if (!factId) yield* Effect.fail(new Error("factId is required: env-facts decide ")) + if (!decision) yield* Effect.fail(new Error("decision is required: env-facts decide ")) + + const result = yield* call((c) => c.deepagent.envFacts.decide({ factId, decision: decision as "adopt" | "reject" })) + if ((result.data as { ok?: boolean })?.ok) console.log(`${decision}: ${factId}`) + else console.log(`Failed: ${factId}`) +})) diff --git a/packages/cli/src/commands/handlers/deepagent/goal.ts b/packages/cli/src/commands/handlers/deepagent/goal.ts new file mode 100644 index 00000000..ba1d1396 --- /dev/null +++ b/packages/cli/src/commands/handlers/deepagent/goal.ts @@ -0,0 +1,40 @@ +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { rawGet, rawPost, requireCapability } from "./util" + +type GoalSnapshot = { goalId: string; planDocId: string; phase: string; running: boolean } + +export default Runtime.handler(Commands.commands.goal, Effect.fn("cli.goal")(function* (input) { + yield* requireCapability("goalLoop") + + if (input.action === "status") { + const result = yield* rawGet<{ goal: GoalSnapshot | null }>(`/deepagent/goal/status?sessionID=${encodeURIComponent(input.sessionID)}`) + const goal = result.data?.goal + if (!goal) { + console.log("No active goal for this session") + return + } + console.log(`Goal: ${goal.goalId}`) + console.log(` Phase: ${goal.phase}`) + console.log(` Running: ${goal.running}`) + console.log(` Plan: ${goal.planDocId}`) + return + } + + if (input.action === "start") { + const body: Record = { sessionID: input.sessionID } + const objective = Option.getOrElse(input.objective, () => undefined) + if (objective) body.objective = objective + const result = yield* rawPost("/deepagent/goal/start", body) + const goal = result.data + if (goal) console.log(`Goal started: ${goal.goalId} (phase: ${goal.phase})`) + else console.log("Goal start failed") + return + } + + // pause / resume / stop — all take { sessionID } + const result = yield* rawPost<{ ok: boolean }>(`/deepagent/goal/${input.action}`, { sessionID: input.sessionID }) + if (result.data?.ok) console.log(`Goal ${input.action}: ${input.sessionID}`) + else console.log(`Goal ${input.action} failed: ${input.sessionID}`) +})) diff --git a/packages/cli/src/commands/handlers/deepagent/packs.ts b/packages/cli/src/commands/handlers/deepagent/packs.ts new file mode 100644 index 00000000..db42a0c6 --- /dev/null +++ b/packages/cli/src/commands/handlers/deepagent/packs.ts @@ -0,0 +1,35 @@ +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { call } from "./util" + +type PackItem = { id: string; name: string; version: string; risk: string; pinned: boolean; builtin?: boolean; description?: string } + +export default Runtime.handler(Commands.commands.packs, Effect.fn("cli.packs")(function* (input) { + if (input.action === "list") { + const result = yield* call((c) => c.deepagent.packsAll()) + const packs = ((result.data as { packs?: PackItem[] })?.packs) ?? [] + for (const p of packs) { + const tags = [p.builtin ? "builtin" : null, p.pinned ? "pinned" : null].filter(Boolean).join(", ") + console.log(`${p.id}${tags ? ` [${tags}]` : ""}`) + console.log(` ${p.name} v${p.version} — risk: ${p.risk}`) + if (p.description) console.log(` ${p.description}`) + } + if (packs.length === 0) console.log("No domain packs found") + return + } + + const packId = Option.getOrElse(input.packId, () => "") + if (!packId) yield* Effect.fail(new Error("packId is required for pin/unpin")) + + if (input.action === "pin") { + const result = yield* call((c) => c.deepagent.packsPin({ packId })) + if ((result.data as { ok?: boolean })?.ok) console.log(`Pinned: ${packId}`) + else console.log(`Failed to pin: ${packId}`) + return + } + + const result = yield* call((c) => c.deepagent.packsUnpin({ packId })) + if ((result.data as { ok?: boolean })?.ok) console.log(`Unpinned: ${packId}`) + else console.log(`Failed to unpin: ${packId}`) +})) diff --git a/packages/cli/src/commands/handlers/deepagent/panel.ts b/packages/cli/src/commands/handlers/deepagent/panel.ts new file mode 100644 index 00000000..073e3192 --- /dev/null +++ b/packages/cli/src/commands/handlers/deepagent/panel.ts @@ -0,0 +1,25 @@ +import { Effect } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { rawGet, requireCapability } from "./util" + +type PanelStatus = { sessionID: string; armed: boolean; explicit: boolean } + +export default Runtime.handler(Commands.commands.panel, Effect.fn("cli.panel")(function* (input) { + yield* requireCapability("expertPanel") + + if (input.action === "status") { + const result = yield* rawGet(`/deepagent/panel/status?sessionID=${encodeURIComponent(input.sessionID)}`) + const status = result.data + if (!status) { + console.log("No panel status available") + return + } + const source = status.explicit ? "explicit" : "default" + console.log(`Session: ${status.sessionID}`) + console.log(` Armed: ${status.armed} (${source})`) + return + } + + yield* Effect.fail(new Error(`Unknown panel action: ${input.action}`)) +})) diff --git a/packages/cli/src/commands/handlers/deepagent/review.ts b/packages/cli/src/commands/handlers/deepagent/review.ts new file mode 100644 index 00000000..e6e79278 --- /dev/null +++ b/packages/cli/src/commands/handlers/deepagent/review.ts @@ -0,0 +1,40 @@ +import { Effect } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { call } from "./util" + +type KnowledgeItem = { + id: string + type: string + summary: string + evidence_strength: string + approval_status: string +} + +export default Runtime.handler(Commands.commands.review, Effect.fn("cli.review")(function* (input) { + if (input.action === "pending") { + const result = yield* call((c) => c.deepagent.knowledge.pending()) + const items = ((result.data as { items?: KnowledgeItem[] })?.items) ?? [] + for (const item of items) { + console.log(`${item.id} [${item.approval_status}] ${item.type} — ${item.evidence_strength}`) + console.log(` ${item.summary}`) + } + if (items.length === 0) console.log("No pending knowledge") + return + } + + // approve / reject + const ids = [...(input.ids as ReadonlyArray)] + if (ids.length === 0) yield* Effect.fail(new Error(`At least one ID is required: review ${input.action} `)) + + if (input.action === "approve") { + const result = yield* call((c) => c.deepagent.knowledge.approve({ ids })) + const updated = (result.data as { updated?: string[] })?.updated ?? [] + console.log(`Approved ${updated.length} item(s): ${updated.join(", ")}`) + return + } + + const result = yield* call((c) => c.deepagent.knowledge.rejectIds({ ids })) + const updated = (result.data as { updated?: string[] })?.updated ?? [] + console.log(`Rejected ${updated.length} item(s): ${updated.join(", ")}`) +})) diff --git a/packages/cli/src/commands/handlers/deepagent/util.ts b/packages/cli/src/commands/handlers/deepagent/util.ts new file mode 100644 index 00000000..e6a8633e --- /dev/null +++ b/packages/cli/src/commands/handlers/deepagent/util.ts @@ -0,0 +1,47 @@ +import { Effect } from "effect" +import { Daemon } from "../../../services/daemon" +import type { createOpencodeClient } from "@deepagent-code/sdk/v2/client" + +type Client = ReturnType + +// The SDK's generated client type doesn't expose `.client.request` — it's the +// raw HTTP escape hatch used by the GUI (see wiki.api.ts). Cast to access it. +type RawRequestClient = { + client: { + request(options: { method: string; url: string; body?: unknown; headers?: Record }): Promise<{ data?: T }> + } +} + +export const getClient = Effect.fn("cli.deepagent.client")(function* () { + return (yield* (yield* Daemon.Service).client()) as Client & RawRequestClient +}) + +export const call = (f: (c: Client) => Promise) => + Effect.gen(function* () { + const c = yield* getClient() + return yield* Effect.tryPromise(() => f(c)) + }) + +export const rawGet = (url: string) => + Effect.gen(function* () { + const c = yield* getClient() + return yield* Effect.tryPromise<{ data?: T }>(() => c.client.request({ method: "GET", url })) + }) + +export const rawPost = (url: string, body?: unknown) => + Effect.gen(function* () { + const c = yield* getClient() + return yield* Effect.tryPromise<{ data?: T }>(() => + c.client.request({ method: "POST", url, body, headers: { "Content-Type": "application/json" } }), + ) + }) + +type Capabilities = { features?: Record } + +export const requireCapability = (flag?: string) => + Effect.gen(function* () { + if (!flag) return + const result = yield* call((c) => c.global.capabilities()) + const features = (result.data as Capabilities | undefined)?.features + if (!features?.[flag]) yield* Effect.fail(new Error(`Feature "${flag}" is not enabled on this server`)) + }) diff --git a/packages/cli/src/commands/handlers/deepagent/wiki.ts b/packages/cli/src/commands/handlers/deepagent/wiki.ts new file mode 100644 index 00000000..d3758f52 --- /dev/null +++ b/packages/cli/src/commands/handlers/deepagent/wiki.ts @@ -0,0 +1,56 @@ +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { rawGet, requireCapability } from "./util" + +type WikiPageSummary = { docId: string; type: string; title: string; scope: string; editable: boolean; version: number } +type WikiPage = { docId: string; type: string; title: string; markdown: string; editable: boolean; version: number } +type WikiSearchHit = { docId: string; type: string; scope: string; title: string; score: number } + +export default Runtime.handler(Commands.commands.wiki, Effect.fn("cli.wiki")(function* (input) { + yield* requireCapability("wiki") + + if (input.action === "list") { + const typeFlag = Option.getOrElse(input.type as Option.Option, () => undefined) + const url = typeFlag ? `/deepagent/wiki/pages?type=${encodeURIComponent(typeFlag)}` : "/deepagent/wiki/pages" + const result = yield* rawGet<{ pages: WikiPageSummary[] }>(url) + const pages = result.data?.pages ?? [] + for (const p of pages) { + console.log(`${p.docId} [${p.type}] ${p.title}`) + console.log(` scope: ${p.scope}, v${p.version}${p.editable ? " (editable)" : ""}`) + } + if (pages.length === 0) console.log("No wiki pages found") + return + } + + if (input.action === "get") { + const docId = input.args[0] as string | undefined + if (!docId) yield* Effect.fail(new Error("docId is required: wiki get ")) + const scope = Option.getOrElse(input.scope as Option.Option, () => "project") + const result = yield* rawGet(`/deepagent/wiki/page?docId=${encodeURIComponent(docId!)}&scope=${encodeURIComponent(scope)}`) + if (!result.data) { + console.log("Page not found") + return + } + console.log(`# ${result.data.title}`) + console.log(`docId: ${result.data.docId} | type: ${result.data.type} | v${result.data.version}`) + console.log("---") + console.log(result.data.markdown) + return + } + + // search + const text = (input.args as ReadonlyArray).join(" ") + if (!text) yield* Effect.fail(new Error("Search text is required: wiki search ")) + const params = new URLSearchParams({ text }) + const typeFlag = Option.getOrElse(input.type as Option.Option, () => undefined) + const scopeFlag = Option.getOrElse(input.scope as Option.Option, () => undefined) + if (typeFlag) params.set("type", typeFlag) + if (scopeFlag) params.set("scope", scopeFlag) + const result = yield* rawGet<{ hits: WikiSearchHit[] }>(`/deepagent/wiki/search?${params.toString()}`) + const hits = result.data?.hits ?? [] + for (const h of hits) { + console.log(`${h.docId} [${h.type}] ${h.title} (score: ${h.score.toFixed(2)})`) + } + if (hits.length === 0) console.log("No results found") +})) diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index d0a9968e..378c9054 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,13 +1,49 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Daemon } from "../../services/daemon" +import { createOpencodeClient } from "@deepagent-code/sdk/v2/client" +import path from "node:path" -export default Runtime.handler(Commands, () => +export default Runtime.handler(Commands, (input) => Effect.gen(function* () { + if (input.fork && !input.continue && Option.isNone(input.session)) + return yield* Effect.fail(new Error("--fork requires --continue or --session")) + + const project = Option.getOrUndefined(input.project) + if (project) { + const resolved = path.resolve(project) + yield* Effect.try({ + try: () => process.chdir(resolved), + catch: () => new Error(`Failed to change directory to ${resolved}`), + }) + } + const directory = process.cwd() + const daemon = yield* Daemon.Service const transport = yield* daemon.transport() + + const sessionID = Option.getOrUndefined(input.session) + if (sessionID) { + const client = createOpencodeClient({ baseUrl: transport.url, directory, headers: transport.headers }) + yield* Effect.tryPromise({ + try: () => client.session.get({ sessionID }, { throwOnError: true }), + catch: () => new Error(`Session not found: ${sessionID}`), + }) + } + const { runTui } = yield* Effect.promise(() => import("../../tui")) - yield* runTui(transport) + yield* runTui( + transport, + { + continue: input.continue, + sessionID, + fork: input.fork, + model: Option.getOrUndefined(input.model), + agent: Option.getOrUndefined(input.agent), + prompt: Option.getOrUndefined(input.prompt), + }, + directory, + ) }), ) diff --git a/packages/cli/src/commands/handlers/export.ts b/packages/cli/src/commands/handlers/export.ts new file mode 100644 index 00000000..1b45b0c5 --- /dev/null +++ b/packages/cli/src/commands/handlers/export.ts @@ -0,0 +1,103 @@ +import { EOL } from "os" +import { Effect, Option } from "effect" +import type { Session, Message, Part, FilePart } from "@deepagent-code/sdk/v2" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { SessionClient } from "../../services/session-client" + +type ExportData = { info: Session; messages: Array<{ info: Message; parts: Part[] }> } + +export default Runtime.handler(Commands.commands.export, (input) => + Effect.gen(function* () { + const sessionID = Option.getOrElse(input.sessionID, () => undefined) + if (!sessionID) { + return yield* Effect.fail(new Error("sessionID is required (usage: dacode export )")) + } + + process.stderr.write(`Exporting session: ${sessionID}\n`) + + const sessionResult = yield* SessionClient.get({ sessionID }) + if (!sessionResult.data) { + return yield* Effect.fail(new Error(`Session not found: ${sessionID}`)) + } + + const messagesResult = yield* SessionClient.messages({ sessionID }) + const messages = messagesResult.data ?? [] + + const exportData: ExportData = { info: sessionResult.data, messages } + const output = input.sanitize ? sanitize(exportData) : exportData + process.stdout.write(JSON.stringify(output, null, 2) + EOL) + }), +) + +function redact(kind: string, id: string, value: string) { + return value.trim() ? `[redacted:${kind}:${id}]` : value +} + +function redactObj(kind: string, id: string, value: Record | undefined) { + if (!value) return value + return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value +} + +function sanitizeFilePart(part: FilePart): FilePart { + return { + ...part, + url: redact("file-url", part.id, part.url), + filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename), + source: !part.source + ? part.source + : part.source.type === "symbol" + ? { ...part.source, path: redact("file-path", part.id, part.source.path), name: redact("file-symbol", part.id, part.source.name), text: { ...part.source.text, value: redact("file-text", part.id, part.source.text.value) } } + : part.source.type === "resource" + ? { ...part.source, clientName: redact("file-client", part.id, part.source.clientName), uri: redact("file-uri", part.id, part.source.uri), text: { ...part.source.text, value: redact("file-text", part.id, part.source.text.value) } } + : { ...part.source, path: redact("file-path", part.id, part.source.path), text: { ...part.source.text, value: redact("file-text", part.id, part.source.text.value) } }, + } +} + +function sanitizePart(part: Part): Part { + switch (part.type) { + case "text": + return { ...part, text: redact("text", part.id, part.text), metadata: redactObj("text-metadata", part.id, part.metadata) } + case "reasoning": + return { ...part, text: redact("reasoning", part.id, part.text), metadata: redactObj("reasoning-metadata", part.id, part.metadata) } + case "file": + return sanitizeFilePart(part) + case "subtask": + return { + ...part, + prompt: redact("subtask-prompt", part.id, part.prompt), + description: redact("subtask-description", part.id, part.description), + command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command), + } + case "tool": + return { ...part, metadata: redactObj("tool-metadata", part.id, part.metadata) } + case "patch": + return { ...part, hash: redact("patch", part.id, part.hash), files: part.files.map((item, i) => redact("patch-file", `${part.id}-${i}`, item)) } + case "snapshot": + return { ...part, snapshot: redact("snapshot", part.id, part.snapshot) } + case "step-start": + case "step-finish": + return part.snapshot === undefined ? part : { ...part, snapshot: redact("snapshot", part.id, part.snapshot) } + case "agent": + return !part.source ? part : { ...part, source: { ...part.source, value: redact("agent-source", part.id, part.source.value) } } + default: + return part + } +} + +function sanitize(data: ExportData): ExportData { + return { + info: { + ...data.info, + title: redact("session-title", data.info.id, data.info.title), + directory: redact("session-directory", data.info.id, data.info.directory), + }, + messages: data.messages.map((msg) => ({ + info: + msg.info.role === "user" + ? { ...msg.info, system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system) } + : { ...msg.info, path: { cwd: redact("cwd", msg.info.id, msg.info.path.cwd), root: redact("root", msg.info.id, msg.info.path.root) } }, + parts: msg.parts.map(sanitizePart), + })), + } +} diff --git a/packages/cli/src/commands/handlers/github.ts b/packages/cli/src/commands/handlers/github.ts new file mode 100644 index 00000000..5b346ce5 --- /dev/null +++ b/packages/cli/src/commands/handlers/github.ts @@ -0,0 +1,19 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect, Option } from "effect" + +export default Runtime.handler(Commands.commands.github, (input) => + Effect.gen(function* () { + const { spawn } = yield* Effect.promise(() => import("node:child_process")) + const args = ["github", input.action] + if (input.action === "run") { + const eventOpt = input.event as Option.Option + const tokenOpt = input.token as Option.Option + if (Option.isSome(eventOpt)) args.push("--event", eventOpt.value) + if (Option.isSome(tokenOpt)) args.push("--token", tokenOpt.value) + } + const child = spawn("deepagent-code", args, { stdio: "inherit" }) + const code = yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve))) + if (code !== 0) yield* Effect.fail(new Error(`github ${input.action} exited with code ${code}`)) + }), +) diff --git a/packages/cli/src/commands/handlers/import.ts b/packages/cli/src/commands/handlers/import.ts new file mode 100644 index 00000000..14d6da80 --- /dev/null +++ b/packages/cli/src/commands/handlers/import.ts @@ -0,0 +1,83 @@ +import { EOL } from "os" +import { Effect } from "effect" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Daemon } from "../../services/daemon" + +export default Runtime.handler(Commands.commands.import, (input) => + Effect.gen(function* () { + if (input.source !== "codex" && input.source !== "claude") { + return yield* Effect.fail(new Error(`--source must be "codex" or "claude", got: ${input.source}`)) + } + + const daemon = yield* Daemon.Service + const transport = yield* daemon.transport() + + process.stderr.write(`Importing from ${input.source}: ${input.file}\n`) + + const response = yield* Effect.tryPromise(() => + fetch(`${transport.url}/global/import`, { + method: "POST", + headers: { ...transport.headers, "Content-Type": "application/json" }, + body: JSON.stringify({ source: input.source, sourcePath: input.file }), + }), + ) + + if (!response.ok) { + const text = yield* Effect.tryPromise(() => response.text()) + return yield* Effect.fail(new Error(`Import failed (${response.status}): ${text}`)) + } + + if (!response.body) { + console.log("Import completed (no progress stream)") + return + } + + yield* consumeSSE(response.body) + }), +) + +function consumeSSE(stream: ReadableStream) { + return Effect.promise(async () => { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + + for (const line of lines) { + if (!line.startsWith("data:")) continue + const data = line.slice(5).trim() + if (!data) continue + try { + const event = JSON.parse(data) + if (event.phase === "done") { + if (event.report) { + console.log(`Imported: ${event.report.sessions ?? 0} sessions, ${event.report.messages ?? 0} messages`) + } + return + } + if (event.phase === "error") { + throw new Error(event.message ?? "Import failed") + } + if (event.message) { + process.stderr.write(`${event.message}${EOL}`) + } + } catch (e) { + if (e instanceof SyntaxError) continue + throw e + } + } + } + } finally { + reader.releaseLock() + } + }) +} diff --git a/packages/cli/src/commands/handlers/mcp/add.ts b/packages/cli/src/commands/handlers/mcp/add.ts new file mode 100644 index 00000000..2e2dbd1b --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/add.ts @@ -0,0 +1,52 @@ +import { Option } from "effect" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +type McpLocalConfig = { type: "local"; command: string[]; environment?: Record } +type McpRemoteConfig = { type: "remote"; url: string; headers?: Record } +type McpConfig = McpLocalConfig | McpRemoteConfig + +function parseEntries(value: string, kind: string): Record { + const result: Record = {} + for (const entry of value.split(",")) { + const idx = entry.indexOf("=") + if (idx < 1) throw new Error(`Invalid ${kind}: ${entry}. Expected KEY=VALUE`) + result[entry.slice(0, idx)] = entry.slice(idx + 1) + } + return result +} + +export default Runtime.handler( + Commands.commands.mcp.commands.add, + Effect.fn("cli.mcp.add")(function* (input) { + const url = Option.getOrNull(input.url) + const command = Option.getOrNull(input.command) + + if (url && command) return yield* Effect.fail(new Error("Provide either --url or --command , not both")) + if (!url && !command) + return yield* Effect.fail( + new Error("Provide --url for a remote server or --command for a local server"), + ) + + const envRaw = Option.getOrNull(input.env) + const headerRaw = Option.getOrNull(input.header) + + const config: McpConfig = url + ? { type: "remote", url, ...(headerRaw ? { headers: parseEntries(headerRaw, "HTTP header") } : {}) } + : { + type: "local", + command: command!.split(" "), + ...(envRaw ? { environment: parseEntries(envRaw, "environment variable") } : {}), + } + + // Persist to global config via daemon — updateGlobal does a deep merge and + // preserves comments in the .jsonc file, then disposes instances so the + // new MCP server is picked up on the next request. + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + yield* Effect.tryPromise(() => client.global.config.update({ config: { mcp: { [input.name]: config } } })) + console.log(`MCP server "${input.name}" added to global config`) + }), +) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts new file mode 100644 index 00000000..291cd27b --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -0,0 +1,63 @@ +import { EOL } from "os" +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.mcp.commands.list, + Effect.fn("cli.mcp.list")(function* () { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const result = yield* Effect.tryPromise(() => client.mcp.status()) + const statuses = result.data ?? {} + + const entries = Object.entries(statuses) + if (entries.length === 0) { + console.log("No MCP servers configured.") + console.log("Add servers with: dacode mcp add --url ") + return + } + + for (const [name, status] of entries) { + const icon = statusIcon(status.status) + const text = statusText(status.status) + const hint = "error" in status ? ` — ${status.error}` : "" + process.stdout.write(`${icon} ${name} ${text}${hint}${EOL}`) + } + + process.stdout.write(`${EOL}${entries.length} server(s)${EOL}`) + }), +) + +function statusIcon(status: string): string { + switch (status) { + case "connected": + return "✓" + case "disabled": + return "○" + case "needs_auth": + return "⚠" + case "needs_client_registration": + return "✗" + default: + return "✗" + } +} + +function statusText(status: string): string { + switch (status) { + case "connected": + return "connected" + case "disabled": + return "disabled" + case "needs_auth": + return "needs authentication" + case "needs_client_registration": + return "needs client registration" + case "failed": + return "failed" + default: + return status + } +} diff --git a/packages/cli/src/commands/handlers/models.ts b/packages/cli/src/commands/handlers/models.ts new file mode 100644 index 00000000..39f64e5d --- /dev/null +++ b/packages/cli/src/commands/handlers/models.ts @@ -0,0 +1,37 @@ +import { EOL } from "os" +import { Effect, Option } from "effect" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Daemon } from "../../services/daemon" + +export default Runtime.handler(Commands.commands.models, (input) => + Effect.gen(function* () { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const result = yield* Effect.tryPromise(() => client.provider.list()) + const providers = result.data?.all ?? [] + + if (Option.isSome(input.provider)) { + const providerID = input.provider.value + const provider = providers.find((p) => p.id === providerID) + if (!provider) return yield* Effect.fail(new Error(`Provider not found: ${providerID}`)) + printProvider(provider.id, provider.models, input.verbose) + return + } + + const sorted = [...providers].sort((a, b) => a.id.localeCompare(b.id)) + for (const provider of sorted) { + printProvider(provider.id, provider.models, input.verbose) + } + }), +) + +function printProvider(providerID: string, models: Record, verbose?: boolean) { + const sorted = Object.entries(models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}${EOL}`) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2) + EOL) + } + } +} diff --git a/packages/cli/src/commands/handlers/pr.ts b/packages/cli/src/commands/handlers/pr.ts new file mode 100644 index 00000000..0e48d3dd --- /dev/null +++ b/packages/cli/src/commands/handlers/pr.ts @@ -0,0 +1,60 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect } from "effect" +import { Daemon } from "../../services/daemon" + +export default Runtime.handler(Commands.commands.pr, (input) => + Effect.gen(function* () { + const { spawn } = yield* Effect.promise(() => import("node:child_process")) + const prNumber = input.number + const localBranch = `pr/${prNumber}` + + console.log(`Fetching and checking out PR #${prNumber}...`) + + const checkoutResult = yield* Effect.promise( + () => + new Promise((resolve) => { + const child = spawn("gh", ["pr", "checkout", String(prNumber), "--branch", localBranch, "--force"], { + stdio: "inherit", + }) + child.on("close", resolve) + }), + ) + if (checkoutResult !== 0) { + yield* Effect.fail( + new Error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`), + ) + } + + const prInfoResult = yield* Effect.promise( + () => + new Promise((resolve) => { + const child = spawn( + "gh", + ["pr", "view", String(prNumber), "--json", "body"], + { stdio: ["pipe", "pipe", "inherit"] }, + ) + let output = "" + child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString())) + child.on("close", () => resolve(output)) + }), + ) + + let sessionID: string | undefined + try { + const prInfo = JSON.parse(prInfoResult) + if (prInfo?.body) { + const match = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) + if (match) sessionID = match[1] + } + } catch {} + + console.log(`Successfully checked out PR #${prNumber} as branch '${localBranch}'`) + console.log("\nStarting DeepAgent Code...\n") + + const daemon = yield* Daemon.Service + const transport = yield* daemon.transport() + const { runTui } = yield* Effect.promise(() => import("../../tui")) + yield* runTui(transport, sessionID ? { sessionID } : {}) + }), +) diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts new file mode 100644 index 00000000..b06dc712 --- /dev/null +++ b/packages/cli/src/commands/handlers/run.ts @@ -0,0 +1,216 @@ +import { EOL } from "os" +import { Effect, Option } from "effect" +import { createOpencodeClient } from "@deepagent-code/sdk/v2/client" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Daemon } from "../../services/daemon" +import { SessionClient } from "../../services/session-client" + +type Client = ReturnType +type ModelInput = { providerID: string; modelID: string } + +export default Runtime.handler(Commands.commands.run, (input) => + Effect.gen(function* () { + const message = input.message.join(" ") + const piped = process.stdin.isTTY ? undefined : yield* Effect.promise(() => Bun.stdin.text()) + const fullMessage = [message, piped].filter(Boolean).join("\n") + + if (!fullMessage.trim()) { + return yield* Effect.fail(new Error("You must provide a message")) + } + + if (input.fork && !input.continue && Option.isNone(input.session)) { + return yield* Effect.fail(new Error("--fork requires --continue or --session")) + } + + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const sessionID = yield* resolveSession(input) + + const abort = new AbortController() + const events = yield* Effect.tryPromise(() => client.event.subscribe(undefined, { signal: abort.signal })) + + const model = pickModel(input.model) + const promptResult = yield* SessionClient.promptAsync({ + sessionID, + parts: [{ type: "text", text: fullMessage }], + model, + agent: Option.getOrElse(input.agent, () => undefined), + variant: Option.getOrElse(input.variant, () => undefined), + }) + + if (promptResult.error) { + yield* Effect.tryPromise(() => events.stream.return(undefined)).pipe(Effect.ignore) + return yield* Effect.fail(new Error(formatError(promptResult.error))) + } + + yield* consumeEvents(client, events, sessionID, input.format, input["dangerously-skip-permissions"], abort) + }), +) + +function pickModel(value: Option.Option): ModelInput | undefined { + if (Option.isNone(value)) return undefined + const [providerID, ...rest] = value.value.split("/") + return { providerID, modelID: rest.join("/") } +} + +function resolveSession(input: { + session: Option.Option + continue: boolean + fork: boolean + model: Option.Option + agent: Option.Option + variant: Option.Option +}) { + if (Option.isSome(input.session)) { + return resolveExistingSession(input.session.value, input.fork) + } + if (input.continue) { + return resolveContinueSession(input.fork) + } + return createNewSession(input.model, input.agent, input.variant) +} + +function resolveExistingSession(sessionID: string, fork: boolean) { + return Effect.gen(function* () { + const result = yield* SessionClient.get({ sessionID }) + if (!result.data) return yield* Effect.fail(new Error("Session not found")) + if (fork) { + const forked = yield* SessionClient.fork({ sessionID }) + const id = forked.data?.id + if (!id) return yield* Effect.fail(new Error("Failed to fork session")) + return id + } + return sessionID + }) +} + +function resolveContinueSession(fork: boolean) { + return Effect.gen(function* () { + const result = yield* SessionClient.list({}) + const base = result.data?.find((s) => !s.parentID) + if (!base) return yield* Effect.fail(new Error("No session to continue")) + if (fork) { + const forked = yield* SessionClient.fork({ sessionID: base.id }) + const id = forked.data?.id + if (!id) return yield* Effect.fail(new Error("Failed to fork session")) + return id + } + return base.id + }) +} + +function createNewSession( + modelFlag: Option.Option, + agent: Option.Option, + variant: Option.Option, +) { + return Effect.gen(function* () { + const model = pickModel(modelFlag) + const result = yield* SessionClient.create({ + agent: Option.getOrElse(agent, () => undefined), + model: model + ? { providerID: model.providerID, id: model.modelID, variant: Option.getOrElse(variant, () => undefined) } + : undefined, + }) + const id = result.data?.id + if (!id) return yield* Effect.fail(new Error("Failed to create session")) + return id + }) +} + +function consumeEvents( + client: Client, + events: Awaited>, + sessionID: string, + format: string, + skipPermissions: boolean, + abort: AbortController, +) { + return Effect.promise(async () => { + let error: string | undefined + + try { + for await (const event of events.stream as AsyncIterable) { + if (format === "json") { + process.stdout.write( + JSON.stringify({ type: event.type, timestamp: Date.now(), sessionID, ...event.properties }) + EOL, + ) + } + + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (format !== "json") { + if (part.type === "text" && part.time?.end) { + const text = part.text.trim() + if (text) process.stdout.write(text + EOL) + } + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + process.stderr.write(`[tool] ${part.tool}${EOL}`) + } + } + } + + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + const err = formatError(props.error) + error = error ? error + EOL + err : err + if (format !== "json") process.stderr.write(err + EOL) + } + + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { + // Abort BEFORE break: the SSE generator only cancels the underlying + // fetch reader via its abort listener, which its own finally removes. + abort.abort() + break + } + + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue + + if (skipPermissions) { + await client.permission.reply({ requestID: permission.id, reply: "once" }) + } else { + process.stderr.write( + `permission requested: ${permission.permission} (${permission.patterns?.join(", ")}); auto-rejecting${EOL}`, + ) + await client.permission.reply({ requestID: permission.id, reply: "reject" }) + } + } + } + } finally { + await events.stream.return(undefined) + } + + if (error) throw new Error(error) + }) +} + +function formatError(error: unknown): string { + if (typeof error !== "object" || error === null) return String(error) + const obj = error as Record + if (typeof obj.data === "object" && obj.data !== null && "message" in obj.data) { + return String((obj.data as Record).message) + } + if (typeof obj.name === "string") return obj.name + return String(error) +} + +type RunInput = { + message: ReadonlyArray + model: Option.Option + agent: Option.Option + format: string + continue: boolean + session: Option.Option + fork: boolean + variant: Option.Option +} diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 357e6e99..823cfc58 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -1,10 +1,6 @@ -import { NodeHttpServer } from "@effect/platform-node" -import { PermissionSaved } from "@deepagent-code/core/permission/saved" -import { Context, Layer, Option } from "effect" +import { Option } from "effect" import * as Effect from "effect/Effect" -import { HttpRouter, HttpServer } from "effect/unstable/http" -import { createServer } from "node:http" -import { createRoutes } from "@deepagent-code/server/routes" +import { HttpServer } from "effect/unstable/http" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Daemon } from "../../services/daemon" @@ -24,27 +20,41 @@ export default Runtime.handler( const envPassword = process.env.DEEPAGENT_CODE_SERVER_PASSWORD const serverMode = typeof envPassword === "string" && envPassword !== "" const password = serverMode ? envPassword : yield* daemon.password() - const address = yield* listen(input.hostname, input.port, password) - if (input.register && !serverMode) yield* daemon.register(address) - console.log(`server listening on ${HttpServer.formatAddress(address)}`) + // The legacy full server (packages/deepagent-code/src/server/server.ts) + // reads its Basic Auth credential from the DEEPAGENT_CODE_SERVER_PASSWORD + // env var via ServerAuth.Config — there is no `password` listen argument. + // Inject it before listen so the complete HttpApi surface (session, + // provider, mcp, config, deepagent/*, oversight/*, ...) is authenticated + // identically to the GUI and the old `deepagent serve` command. + if (password) process.env.DEEPAGENT_CODE_SERVER_PASSWORD = password + if (!password) console.log("Warning: DEEPAGENT_CODE_SERVER_PASSWORD is not set; server is unsecured.") + const { Server } = yield* Effect.promise(() => import("deepagent-code/server/server")) + const listener = yield* Effect.promise(() => + Server.listen({ + hostname: input.hostname, + port: Option.isSome(input.port) ? input.port.value : 0, + mdns: input.mdns, + mdnsDomain: input["mdns-domain"], + cors: Option.isSome(input.cors) + ? input.cors.value + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean) + : [], + }), + ) + yield* Effect.addFinalizer(() => Effect.promise(() => listener.stop())) + if (input.register && !serverMode) { + const address: HttpServer.Address = { + _tag: "TcpAddress", + hostname: listener.hostname, + port: listener.port, + } + yield* daemon.register(address) + } + console.log(`server listening on ${listener.url.toString()}`) return yield* Effect.never }), ) }), ) - -function listen(hostname: string, port: Option.Option, password: string) { - if (Option.isSome(port)) return bind(hostname, port.value, password) - // Preserve the familiar default when available, but let the OS choose a free - // port when another local server already owns 4096. - return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password))) -} - -function bind(hostname: string, port: number, password: string) { - return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })), - Layer.provide(PermissionSaved.defaultLayer), - ), - ).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address)) -} diff --git a/packages/cli/src/commands/handlers/session/delete.ts b/packages/cli/src/commands/handlers/session/delete.ts new file mode 100644 index 00000000..f3aab827 --- /dev/null +++ b/packages/cli/src/commands/handlers/session/delete.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { SessionClient } from "../../../services/session-client" + +export default Runtime.handler(Commands.commands.session.commands.delete, (input) => + Effect.gen(function* () { + const result = yield* SessionClient.deleteSession({ sessionID: input.sessionID }) + if (result.error) { + return yield* Effect.fail(new Error(`Session not found: ${input.sessionID}`)) + } + console.log(`Session ${input.sessionID} deleted`) + }), +) diff --git a/packages/cli/src/commands/handlers/session/list.ts b/packages/cli/src/commands/handlers/session/list.ts new file mode 100644 index 00000000..06b5e2a4 --- /dev/null +++ b/packages/cli/src/commands/handlers/session/list.ts @@ -0,0 +1,78 @@ +import { EOL } from "os" +import { spawn } from "node:child_process" +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { SessionClient } from "../../../services/session-client" +import type { Session } from "@deepagent-code/sdk/v2" + +export default Runtime.handler(Commands.commands.session.commands.list, (input) => + Effect.gen(function* () { + const limit = Option.getOrElse(input["max-count"], () => undefined) + const result = yield* SessionClient.list({ roots: true, limit }) + const sessions = result.data ?? [] + if (sessions.length === 0) return + + const output = input.format === "json" ? formatJSON(sessions) : formatTable(sessions) + + const shouldPaginate = process.stdout.isTTY && Option.isNone(input["max-count"]) && input.format === "table" + if (shouldPaginate) { + yield* paginate(output) + } else { + console.log(output) + } + }), +) + +function formatTable(sessions: Session[]): string { + const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length)) + const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length)) + + const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated` + const lines = [header, "─".repeat(header.length)] + + for (const session of sessions) { + const truncatedTitle = session.title.length > maxTitleWidth ? session.title.slice(0, maxTitleWidth) : session.title + const timeStr = todayTimeOrDateTime(session.time.updated) + lines.push(`${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}`) + } + + return lines.join(EOL) +} + +function formatJSON(sessions: Session[]): string { + return JSON.stringify( + sessions.map((session) => ({ + id: session.id, + title: session.title, + updated: session.time.updated, + created: session.time.created, + projectId: session.projectID, + directory: session.directory, + })), + null, + 2, + ) +} + +function todayTimeOrDateTime(timestamp: number): string { + const date = new Date(timestamp) + const now = new Date() + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString() + } + return date.toLocaleString() +} + +function paginate(output: string) { + return Effect.promise(() => { + const proc = spawn("less", ["-R", "-S"], { stdio: ["pipe", "inherit", "inherit"] }) + if (!proc.stdin) { + console.log(output) + return Promise.resolve() + } + proc.stdin.write(output) + proc.stdin.end() + return new Promise((resolve) => proc.on("exit", () => resolve())) + }) +} diff --git a/packages/cli/src/commands/handlers/stats.ts b/packages/cli/src/commands/handlers/stats.ts new file mode 100644 index 00000000..983183ff --- /dev/null +++ b/packages/cli/src/commands/handlers/stats.ts @@ -0,0 +1,57 @@ +import { Effect, Option } from "effect" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { SessionClient } from "../../services/session-client" + +export default Runtime.handler(Commands.commands.stats, (input) => + Effect.gen(function* () { + const result = yield* SessionClient.list({}) + const sessions = result.data ?? [] + + const MS_IN_DAY = 24 * 60 * 60 * 1000 + const cutoff = Option.match(input.days, { + onNone: () => 0, + onSome: (days) => (days === 0 ? new Date().setHours(0, 0, 0, 0) : Date.now() - days * MS_IN_DAY), + }) + + const filtered = cutoff > 0 ? sessions.filter((s) => s.time.updated >= cutoff) : sessions + + const stats = { + totalSessions: filtered.length, + totalCost: 0, + totalTokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + + for (const session of filtered) { + stats.totalCost += session.cost ?? 0 + if (session.tokens) { + stats.totalTokens.input += session.tokens.input ?? 0 + stats.totalTokens.output += session.tokens.output ?? 0 + stats.totalTokens.reasoning += session.tokens.reasoning ?? 0 + stats.totalTokens.cache.read += session.tokens.cache?.read ?? 0 + stats.totalTokens.cache.write += session.tokens.cache?.write ?? 0 + } + } + + if (input.format === "json") { + console.log(JSON.stringify(stats, null, 2)) + return + } + + const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning + console.log(`Sessions: ${stats.totalSessions}`) + console.log(`Cost: $${stats.totalCost.toFixed(2)}`) + console.log(`Tokens:`) + console.log(` Input: ${formatNumber(stats.totalTokens.input)}`) + console.log(` Output: ${formatNumber(stats.totalTokens.output)}`) + console.log(` Cache R: ${formatNumber(stats.totalTokens.cache.read)}`) + console.log(` Cache W: ${formatNumber(stats.totalTokens.cache.write)}`) + console.log(` Total: ${formatNumber(totalTokens)}`) + }), +) + +function formatNumber(num: number): string { + if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + "M" + if (num >= 1_000) return (num / 1_000).toFixed(1) + "K" + return num.toString() +} diff --git a/packages/cli/src/commands/handlers/uninstall.ts b/packages/cli/src/commands/handlers/uninstall.ts new file mode 100644 index 00000000..6838a101 --- /dev/null +++ b/packages/cli/src/commands/handlers/uninstall.ts @@ -0,0 +1,93 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect } from "effect" +import { Global } from "@deepagent-code/core/global" +import fs from "fs/promises" +import os from "os" + +type InstallMethod = "curl" | "npm" | "pnpm" | "bun" | "yarn" | "brew" | "choco" | "scoop" | "unknown" + +export default Runtime.handler(Commands.commands.uninstall, (input) => + Effect.gen(function* () { + const { Installation } = yield* Effect.promise(() => import("deepagent-code/installation/index")) + const method = (yield* Effect.promise(() => Installation.method())) as InstallMethod + console.log(`Installation method: ${method}`) + + const dirs = [ + { path: Global.Path.data, label: "Data", keep: input["keep-data"] }, + { path: Global.Path.cache, label: "Cache", keep: false }, + { path: Global.Path.config, label: "Config", keep: input["keep-config"] }, + { path: Global.Path.state, label: "State", keep: false }, + ] + + console.log("\nThe following will be removed:") + for (const dir of dirs) { + const exists = yield* Effect.promise(() => fs.access(dir.path).then(() => true).catch(() => false)) + if (!exists) continue + const status = dir.keep ? " (keeping)" : "" + console.log(` ${dir.keep ? "○" : "✓"} ${dir.label}: ${shortenPath(dir.path)}${status}`) + } + + if (input["dry-run"]) { + console.log("\nDry run - no changes made") + return + } + + if (!input.force) { + const { createInterface } = yield* Effect.promise(() => import("node:readline")) + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const answer = yield* Effect.promise( + () => + new Promise((resolve) => { + rl.question("Are you sure? (y/N) ", (a) => { + rl.close() + resolve(a) + }) + }), + ) + if (answer.toLowerCase() !== "y") { + console.log("Cancelled") + return + } + } + + for (const dir of dirs) { + if (dir.keep) continue + const exists = yield* Effect.promise(() => fs.access(dir.path).then(() => true).catch(() => false)) + if (!exists) continue + yield* Effect.promise(() => fs.rm(dir.path, { recursive: true, force: true })) + console.log(`Removed ${dir.label}`) + } + + if (method !== "curl" && method !== "unknown") { + const cmds: Record = { + npm: ["npm", "uninstall", "-g", "deepagent-code"], + pnpm: ["pnpm", "uninstall", "-g", "deepagent-code"], + bun: ["bun", "remove", "-g", "deepagent-code"], + brew: ["brew", "uninstall", "deepagent-code"], + choco: ["choco", "uninstall", "deepagent-code", "-y", "-r"], + scoop: ["scoop", "uninstall", "deepagent-code"], + } + const cmd = cmds[method] + if (cmd) { + const { spawn } = yield* Effect.promise(() => import("node:child_process")) + console.log(`Running ${cmd.join(" ")}...`) + yield* Effect.promise( + () => + new Promise((resolve) => { + const child = spawn(cmd[0], cmd.slice(1), { stdio: "inherit" }) + child.on("close", () => resolve()) + }), + ) + } + } + + console.log("\nThank you for using DeepAgent Code!") + }), +) + +function shortenPath(p: string): string { + const home = os.homedir() + if (p.startsWith(home)) return p.replace(home, "~") + return p +} diff --git a/packages/cli/src/commands/handlers/upgrade.ts b/packages/cli/src/commands/handlers/upgrade.ts new file mode 100644 index 00000000..c3b0ab7c --- /dev/null +++ b/packages/cli/src/commands/handlers/upgrade.ts @@ -0,0 +1,43 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect, Option } from "effect" + +export default Runtime.handler(Commands.commands.upgrade, (input) => + Effect.gen(function* () { + const { Installation } = yield* Effect.promise(() => import("deepagent-code/installation/index")) + const { InstallationVersion } = yield* Effect.promise(() => + import("@deepagent-code/core/installation/version"), + ) + + const methodOpt = input.method as Option.Option + const detectedMethod = yield* Effect.promise(() => Installation.method()) + const methodRaw = Option.getOrElse(methodOpt, () => detectedMethod) + const method = methodRaw as string + + if (method === "unknown") { + yield* Effect.fail( + new Error( + `deepagent-code is installed to ${process.execPath} and may be managed by a package manager. Use --method to specify.`, + ), + ) + } + + console.log(`Using method: ${method}`) + const targetOpt = input.target as Option.Option + const targetRaw = Option.getOrElse(targetOpt, () => undefined) + const target = targetRaw ? targetRaw.replace(/^v/, "") : yield* Effect.promise(() => Installation.latest()) + + if (InstallationVersion === target) { + console.log(`deepagent-code upgrade skipped: ${target} is already installed`) + return + } + + console.log(`From ${InstallationVersion} → ${target}`) + yield* Effect.tryPromise({ + try: () => Installation.upgrade(method as never, target), + catch: (err) => + new Error(`Upgrade failed: ${err instanceof Error ? err.message : String(err)}`), + }) + console.log("Upgrade complete") + }), +) diff --git a/packages/cli/src/commands/handlers/web.ts b/packages/cli/src/commands/handlers/web.ts new file mode 100644 index 00000000..49b1e0dc --- /dev/null +++ b/packages/cli/src/commands/handlers/web.ts @@ -0,0 +1,37 @@ +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Effect, Option } from "effect" +import { Flag } from "@deepagent-code/core/flag/flag" + +export default Runtime.handler(Commands.commands.web, (input) => + Effect.gen(function* () { + const { Server } = yield* Effect.promise(() => import("deepagent-code/server/server")) + if (!Flag.DEEPAGENT_CODE_SERVER_PASSWORD) { + console.log("Warning: DEEPAGENT_CODE_SERVER_PASSWORD is not set; server is unsecured.") + } + + const corsStr = Option.getOrElse(input.cors as Option.Option, () => undefined) + const cors = corsStr ? corsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined + + const port = Option.getOrElse(input.port as Option.Option, () => 0) + + const server = yield* Effect.promise(() => + Server.listen({ + hostname: input.hostname, + port, + mdns: input.mdns, + mdnsDomain: input["mdns-domain"], + cors, + }), + ) + + console.log(`\n Backend: ${server.url.toString()}`) + console.log(` Web interface: ${server.url.toString()}\n`) + + const { spawn } = yield* Effect.promise(() => import("node:child_process")) + const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open" + spawn(cmd, [server.url.toString()], { detached: true, stdio: "ignore" }).unref() + + yield* Effect.never + }), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c15362ac..c5b3be3e 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,44 @@ const Handlers = Runtime.handlers(Commands, { agents: () => import("./commands/handlers/debug/agents"), }, migrate: () => import("./commands/handlers/migrate"), + models: () => import("./commands/handlers/models"), + run: () => import("./commands/handlers/run"), + export: () => import("./commands/handlers/export"), + stats: () => import("./commands/handlers/stats"), + import: () => import("./commands/handlers/import"), + auth: { + login: () => import("./commands/handlers/auth/login"), + list: () => import("./commands/handlers/auth/list"), + logout: () => import("./commands/handlers/auth/logout"), + }, + agent: { + list: () => import("./commands/handlers/agent/list"), + }, + mcp: { + list: () => import("./commands/handlers/mcp/list"), + add: () => import("./commands/handlers/mcp/add"), + }, + session: { + list: () => import("./commands/handlers/session/list"), + delete: () => import("./commands/handlers/session/delete"), + }, + packs: () => import("./commands/handlers/deepagent/packs"), + wiki: () => import("./commands/handlers/deepagent/wiki"), + review: () => import("./commands/handlers/deepagent/review"), + "env-facts": () => import("./commands/handlers/deepagent/env-facts"), + goal: () => import("./commands/handlers/deepagent/goal"), + panel: () => import("./commands/handlers/deepagent/panel"), + attach: () => import("./commands/handlers/attach"), + db: { + $: () => import("./commands/handlers/db"), + path: () => import("./commands/handlers/db/path"), + }, + web: () => import("./commands/handlers/web"), + upgrade: () => import("./commands/handlers/upgrade"), + uninstall: () => import("./commands/handlers/uninstall"), + pr: () => import("./commands/handlers/pr"), + acp: () => import("./commands/handlers/acp"), + github: () => import("./commands/handlers/github"), service: { start: () => import("./commands/handlers/service/start"), restart: () => import("./commands/handlers/service/restart"), diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 3cc3fa99..aed96c10 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -128,7 +128,7 @@ export const layer = Layer.effect( }) return yield* compatible().pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), + Effect.retry(Schedule.spaced("100 millis").pipe(Schedule.both(Schedule.recurs(600)))), Effect.map((info) => info.url), Effect.mapError(() => new Error("Failed to start server")), ) diff --git a/packages/cli/src/services/session-client.ts b/packages/cli/src/services/session-client.ts new file mode 100644 index 00000000..cc6dcd7c --- /dev/null +++ b/packages/cli/src/services/session-client.ts @@ -0,0 +1,50 @@ +import { Effect } from "effect" +import { createOpencodeClient } from "@deepagent-code/sdk/v2/client" +import { Daemon } from "./daemon" + +// Migration seam for session-execution calls. +// +// Every CLI command that creates/prompts/aborts/steers a session must go +// through `SessionClient` instead of calling `client.session.*` directly. +// Internally this targets the legacy top-level endpoints (client.session.*), +// the only surface with a complete session lifecycle — matching the GUI and +// the old `deepagent run`. See `tmp/deepagentcore-cli-parity-impl-plan.md` +// Wave 0 task 5. +// +// When the durable SessionV2 engine swap (deepagentcoredurable) completes and +// the execution surface moves from legacy to the V2 durable path, only this +// module needs to change — not every command. + +type Client = ReturnType + +const client = Effect.fn("cli.SessionClient.client")(function* () { + return yield* (yield* Daemon.Service).client() +}) + +const call = (f: (c: Client) => Promise) => + Effect.gen(function* () { + const c = yield* client() + return yield* Effect.tryPromise(() => f(c)) + }) + +export const create = (opts: Parameters[0]) => + call((c) => c.session.create(opts)) + +export const promptAsync = (opts: Parameters[0]) => + call((c) => c.session.promptAsync(opts)) + +export const abort = (opts: Parameters[0]) => call((c) => c.session.abort(opts)) + +export const status = () => call((c) => c.session.status()) + +export const get = (opts: Parameters[0]) => call((c) => c.session.get(opts)) + +export const list = (opts: Parameters[0]) => call((c) => c.session.list(opts)) + +export const fork = (opts: Parameters[0]) => call((c) => c.session.fork(opts)) + +export const messages = (opts: Parameters[0]) => call((c) => c.session.messages(opts)) + +export const deleteSession = (opts: Parameters[0]) => call((c) => c.session.delete(opts)) + +export * as SessionClient from "./session-client" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 31312d99..e206f9f7 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -1,36 +1,73 @@ import { run } from "@deepagent-code/tui" -import { TuiConfig } from "@deepagent-code/tui/config" +import type { Args } from "@deepagent-code/tui/context/args" import { Effect } from "effect" import { Global } from "@deepagent-code/core/global" +import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" +import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules" -export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { - const config = TuiConfig.resolve({}, { terminalSuspend: false }) - return run({ - ...transport, - args: {}, - config, - fetch: gracefulFetch, - pluginHost: { - async start() {}, - async dispose() {}, - }, - }).pipe(Effect.provide(Global.defaultLayer)) -} +ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) -const legacyDefaults: Record = { - "/config/providers": { providers: [], default: {} }, - "/provider": { all: [], default: {}, connected: [] }, - "/agent": [], - "/config": {}, -} +export function runTui( + transport: { url: string; headers: RequestInit["headers"] }, + args: Args, + directory?: string, +) { + return Effect.tryPromise({ + try: async () => { + const origStderrWrite = process.stderr.write.bind(process.stderr) + const origConsoleLog = console.log + const origConsoleInfo = console.info + const origConsoleError = console.error + const origConsoleWarn = console.warn + const noop = () => {} + const stderrWritable = process.stderr.write + try { + Object.defineProperty(process.stderr, "write", { + value: () => true, + writable: true, + configurable: true, + }) + } catch { + process.stderr.write = () => true + } + console.log = noop + console.info = noop + console.error = noop + console.warn = noop -const gracefulFetch = Object.assign( - async (input: RequestInfo | URL, init?: RequestInit) => { - const response = await fetch(input, init) - if (response.status !== 404) return response - const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname] - if (fallback === undefined) return response - return Response.json(fallback) - }, - { preconnect: fetch.preconnect }, -) + let config: unknown + let pluginHost: unknown + try { + const { TuiConfig: LegacyTuiConfig } = await import("deepagent-code/config/tui") + config = await LegacyTuiConfig.get() + const { createLegacyTuiPluginHost } = await import("deepagent-code/plugin/tui/runtime") + pluginHost = createLegacyTuiPluginHost() + + await Effect.runPromise( + run({ + ...transport, + args, + config: config as never, + directory, + pluginHost: pluginHost as never, + }).pipe(Effect.provide(Global.defaultLayer)), + ) + } finally { + try { + Object.defineProperty(process.stderr, "write", { + value: stderrWritable, + writable: true, + configurable: true, + }) + } catch { + process.stderr.write = stderrWritable + } + console.log = origConsoleLog + console.info = origConsoleInfo + console.error = origConsoleError + console.warn = origConsoleWarn + } + }, + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }) +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index ac9f4c63..f2b55ca1 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,6 +5,9 @@ "jsx": "preserve", "jsxImportSource": "@opentui/solid", "lib": ["ESNext", "DOM", "DOM.Iterable"], - "noUncheckedIndexedAccess": false + "noUncheckedIndexedAccess": false, + "paths": { + "@/*": ["../deepagent-code/src/*"] + } } } diff --git a/packages/containers/README.md b/packages/containers/README.md index a1cfe60d..b3d7b757 100644 --- a/packages/containers/README.md +++ b/packages/containers/README.md @@ -15,8 +15,8 @@ Images Build ``` -REGISTRY=ghcr.io/anomalyco TAG=24.04 bun ./packages/containers/script/build.ts -REGISTRY=ghcr.io/anomalyco TAG=24.04 bun ./packages/containers/script/build.ts --push +REGISTRY=ghcr.io/deepagent-ltd TAG=24.04 bun ./packages/containers/script/build.ts +REGISTRY=ghcr.io/deepagent-ltd TAG=24.04 bun ./packages/containers/script/build.ts --push ``` Workflow usage @@ -26,7 +26,7 @@ jobs: build-cli: runs-on: ubuntu-latest container: - image: ghcr.io/anomalyco/build/bun-node:24.04 + image: ghcr.io/deepagent-ltd/build/bun-node:24.04 ``` Notes diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index 635ac62f..76111c9c 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -1,4 +1,4 @@ -ARG REGISTRY=ghcr.io/anomalyco +ARG REGISTRY=ghcr.io/deepagent-ltd FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] diff --git a/packages/containers/publish/Dockerfile b/packages/containers/publish/Dockerfile index 4780d227..3b5080b7 100644 --- a/packages/containers/publish/Dockerfile +++ b/packages/containers/publish/Dockerfile @@ -1,4 +1,4 @@ -ARG REGISTRY=ghcr.io/anomalyco +ARG REGISTRY=ghcr.io/deepagent-ltd FROM ${REGISTRY}/build/bun-node:24.04 ARG DEBIAN_FRONTEND=noninteractive diff --git a/packages/containers/rust/Dockerfile b/packages/containers/rust/Dockerfile index 533f348b..a5a82764 100644 --- a/packages/containers/rust/Dockerfile +++ b/packages/containers/rust/Dockerfile @@ -1,4 +1,4 @@ -ARG REGISTRY=ghcr.io/anomalyco +ARG REGISTRY=ghcr.io/deepagent-ltd FROM ${REGISTRY}/build/bun-node:24.04 ARG RUST_TOOLCHAIN=stable diff --git a/packages/containers/script/build.ts b/packages/containers/script/build.ts index 651eeed8..97a4d9a0 100644 --- a/packages/containers/script/build.ts +++ b/packages/containers/script/build.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "url" const rootDir = fileURLToPath(new URL("../../..", import.meta.url)) process.chdir(rootDir) -const reg = process.env.REGISTRY ?? "ghcr.io/anomalyco" +const reg = process.env.REGISTRY ?? "ghcr.io/deepagent-ltd" const tag = process.env.TAG ?? "24.04" const push = process.argv.includes("--push") || process.env.PUSH === "1" diff --git a/packages/containers/tauri-linux/Dockerfile b/packages/containers/tauri-linux/Dockerfile index 9f67a280..b9b372f2 100644 --- a/packages/containers/tauri-linux/Dockerfile +++ b/packages/containers/tauri-linux/Dockerfile @@ -1,4 +1,4 @@ -ARG REGISTRY=ghcr.io/anomalyco +ARG REGISTRY=ghcr.io/deepagent-ltd FROM ${REGISTRY}/build/rust:24.04 ARG DEBIAN_FRONTEND=noninteractive diff --git a/packages/core/src/agent-gateway.ts b/packages/core/src/agent-gateway.ts index 562458c3..eab8efdb 100644 --- a/packages/core/src/agent-gateway.ts +++ b/packages/core/src/agent-gateway.ts @@ -258,6 +258,25 @@ let current: CurrentConfig = { export const selfLearningPolicy = (): SelfLearningPolicy => current.selfLearning +// G31-3: minimal in-memory audit counters for general-mode (passthrough) turns. +// No disk writes — available only for diagnostics / budget tracking in-process. +type GeneralAuditEntry = { turnCount: number; totalInputTokens: number; totalOutputTokens: number } +const generalAuditState = new Map() + +const recordGeneralAudit = (sessionID: string, inputTokens: number, outputTokens: number): void => { + const existing = generalAuditState.get(sessionID) + if (existing) { + existing.turnCount += 1 + existing.totalInputTokens += inputTokens + existing.totalOutputTokens += outputTokens + } else { + generalAuditState.set(sessionID, { turnCount: 1, totalInputTokens: inputTokens, totalOutputTokens: outputTokens }) + } +} + +/** Returns the accumulated general-mode audit counters for a session, or undefined if none recorded. */ +export const getGeneralAudit = (sessionID: string): GeneralAuditEntry | undefined => generalAuditState.get(sessionID) + export const configure = (config: Config = {}) => { const nextRunsDir = "runsDir" in config ? config.runsDir : current.runsDir // P0-0: memory/state always root at the single storage home. Production injects `baseDir` @@ -497,8 +516,24 @@ export const manageStream = ( } // general mode (and a disabled runtime) is pure passthrough with zero artifacts, which // protects the inherited generic-agent (deepagent-code) baseline. + // G31-3: even in passthrough mode, tap finish events to record a minimal audit entry + // (turn count + token delta) so diagnostics/budget tracking have visibility into these turns. const agentMode = effectiveAgentMode(input.metadata) ?? current.agentMode - if (!isManagedDeepAgentRuntimeWith({ ...current, agentMode })) return stream + if (!isManagedDeepAgentRuntimeWith({ ...current, agentMode })) { + if (!input.sessionID) return stream + const sessionID = input.sessionID + return Stream.tap(stream, (event) => + Effect.sync(() => { + if (LLMEvent.is.finish(event) && "usage" in event && event.usage != null) { + recordGeneralAudit( + sessionID, + Math.trunc(event.usage.inputTokens ?? 0), + Math.trunc(event.usage.outputTokens ?? 0), + ) + } + }), + ) + } if (input.sessionID) { const budgetStatus = DeepAgentSessionState.budgetStatus(input.sessionID) if (budgetStatus?.status === "exhausted" || budgetStatus?.status === "exceeded") { @@ -703,9 +738,10 @@ const learningQueue = new DeepAgentBackgroundLearning.LearningQueue() export const enqueueLearning = (job: DeepAgentBackgroundLearning.LearningJob): void => learningQueue.enqueue(job) -// Test/shutdown hook: drain any queued learning jobs now (synchronously) and await completion. +// Test/shutdown hook: drain any queued learning jobs now and await completion. +// drainNow() is async since H32-2 made LearningWorker.run() async (reviewer seam). export const flushLearning = async (): Promise => { - learningQueue.drainNow() + await learningQueue.drainNow() } const runBackgroundLearning = (run: RunRecord, finalStatus: "completed" | "failed"): void => { @@ -750,6 +786,20 @@ const runBackgroundLearning = (run: RunRecord, finalStatus: "completed" | "faile finalStatus, trigger: "session_finalization", policy, + // TODO H32-2: inject a model-based reviewer here to validate candidates before the + // governance loop. The reviewer MUST use a fresh session with NO history so it evaluates + // each candidate in isolation. Example shape: + // + // reviewer: async (candidates) => { + // // Open a fresh, ephemeral session using a small model (e.g. "small" preset). + // // Pass ONLY `candidates` — no round state, no session messages, no tool outputs. + // // Return a filtered/annotated subset. The LearningWorker falls back to the full + // // extraction if this throws, so failures here are always non-fatal. + // return reviewCandidatesWithFreshSession(candidates, current.baseDir) + // }, + // + // The LearningWorker itself is already structurally isolated (receives only plain data), + // so no isolation flag is needed — isolation is enforced by this injection contract. }, } }, diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index e1c2575d..8369ac8c 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -96,21 +96,12 @@ export const layer = Layer.effect( .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) } - const sessions = isFilesystemRoot(current.location.directory) - ? conversationTree(current, yield* session.list({ directory: current.location.directory })) - : [current] - const timestamp = yield* DateTime.now - yield* Effect.forEach( - sessions, - (item) => - events.publish(SessionEvent.Moved, { - sessionID: item.id, - location: Location.Ref.make({ directory }), - subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), - timestamp, - }), - { discard: true }, - ) + yield* events.publish(SessionEvent.Moved, { + sessionID: input.sessionID, + location: Location.Ref.make({ directory }), + subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), + timestamp: yield* DateTime.now, + }) if (patch) { yield* git.softResetChanges(current.location.directory).pipe( @@ -130,27 +121,6 @@ export const layer = Layer.effect( }), ) -function isFilesystemRoot(directory: string) { - const resolved = path.resolve(directory) - return path.dirname(resolved) === resolved -} - -function conversationTree(current: SessionSchema.Info, sessions: SessionSchema.Info[]) { - const byID = new Map(sessions.map((session) => [session.id, session])) - const root = sessions.reduce( - (session) => (session.parentID ? (byID.get(session.parentID) ?? session) : session), - current, - ) - const result: SessionSchema.Info[] = [] - const append = (session: SessionSchema.Info) => { - if (result.some((item) => item.id === session.id)) return - result.push(session) - sessions.filter((item) => item.parentID === session.id).forEach(append) - } - append(root) - return result -} - export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(EventV2.defaultLayer), diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 233ea0b0..715ec449 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -52,5 +52,6 @@ export const migrations = ( import("./migration/20260712030000_deepagent_rollback"), import("./migration/20260712040000_deepagent_event_drop_distinct"), import("./migration/20260712050000_session_steer_queue"), + import("./migration/20260719000000_deepagent_consumer_group"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260719000000_deepagent_consumer_group.ts b/packages/core/src/database/migration/20260719000000_deepagent_consumer_group.ts new file mode 100644 index 00000000..4d009003 --- /dev/null +++ b/packages/core/src/database/migration/20260719000000_deepagent_consumer_group.ts @@ -0,0 +1,39 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +/** + * Migration: DeepAgent persistent consumer group registry (K40-2, v4.0.4) + * + * Adds `deepagent_consumer_group` so consumer group identity survives process restarts. + * Previously, consumer groups were in-memory only: a group was registered for the lifetime + * of its live `subscribe({group})` stream and dropped when the last stream unsubscribed. + * This meant that offline/never-live groups NEVER received delivery rows — `publish` only + * wrote delivery rows for groups with a live stream at publish time. + * + * With this table, a group can call `registerConsumerGroup` (durable) independently of + * whether it has an active stream. `publish` now queries BOTH the in-memory live-group Map + * AND this table, so an offline group gets delivery rows and can catch up via `dueRetries` + * + `replay` on reconnect. + * + * `last_seen_at` is updated on every live stream connect/disconnect so a future maintenance + * sweep can prune groups that have been permanently offline (not yet wired; placeholder). + */ +export default { + id: "20260719000000_deepagent_consumer_group", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE IF NOT EXISTS \`deepagent_consumer_group\` ( + \`group_id\` text PRIMARY KEY NOT NULL, + \`type_filter\` text, + \`registered_at\` integer NOT NULL, + \`last_seen_at\` integer NOT NULL + ); + `) + yield* tx.run(` + CREATE INDEX IF NOT EXISTS \`deepagent_consumer_group_type_idx\` + ON \`deepagent_consumer_group\` (\`type_filter\`); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/deepagent/background-learning.ts b/packages/core/src/deepagent/background-learning.ts index 326baf69..2bc56871 100644 --- a/packages/core/src/deepagent/background-learning.ts +++ b/packages/core/src/deepagent/background-learning.ts @@ -27,6 +27,18 @@ export type LearningWorkerInput = { readonly finalStatus: "completed" | "failed" readonly trigger: LearningTrigger readonly policy?: LearningPolicy + // H32-2: Optional reviewer injected by the caller (e.g. runBackgroundLearning in agent-gateway.ts). + // The reviewer receives only the extracted candidates — no session history, no run context, no tool + // state — ensuring it evaluates each candidate in isolation (no hidden evaluator context leaking in). + // It returns a filtered/annotated subset; the governance loop then runs on the reviewer's output. + // + // The LearningWorker itself is already structurally isolated (receives only a plain data struct), + // so no "isolation flag" is needed — isolation is enforced by the injection contract: the caller + // MUST NOT close over live session state inside the reviewer closure. + // + // TODO H32-2: wire a real model-based reviewer in runBackgroundLearning (agent-gateway.ts) using + // a fresh session (no history) and a small model. See scout notes for injection-point details. + readonly reviewer?: (candidates: readonly LearningCandidate[]) => Promise } export type MemoryInboxItem = { @@ -87,7 +99,7 @@ export class LearningWorker { this.store = store ?? new DurableKnowledgeStore(path.join(paths.root, "knowledge")) } - run(input: LearningWorkerInput): LearningWorkerResult { + async run(input: LearningWorkerInput): Promise { const started = Date.now() const extraction = Learning.extract({ runId: input.runID, @@ -96,12 +108,24 @@ export class LearningWorker { totalRounds: input.totalRounds, finalStatus: input.finalStatus, }) + // H32-2: if a reviewer is injected, pass ONLY the extracted candidates — no session state, no + // history, no run context. The reviewer may filter or annotate; we proceed on its output. + // If the reviewer throws, fall back to the full extraction to keep the learning pass non-fatal. + let candidates: readonly LearningCandidate[] = extraction.candidates + if (input.reviewer && candidates.length > 0) { + try { + candidates = await input.reviewer(candidates) + } catch { + // Non-fatal: reviewer failure must not block the learning pass. + candidates = extraction.candidates + } + } const policy = input.policy ?? "auto_merge_safe_project" const autoMerged: string[] = [] const inbox: string[] = [] const skipped: string[] = [] - for (const candidate of extraction.candidates) { + for (const candidate of candidates) { // U6 governance pipeline (S1 §P1): default fully automatic; route to a human ONLY for the four // cases a machine can't safely decide (sensitive / high-trust contradiction / pack promotion / // global promotion). Gates 3/4 (exact dedup + near-dup merge) live in the store's @@ -373,13 +397,16 @@ export class LearningQueue { } } - private drain(): void { + private async drain(): Promise { try { while (this.jobs.length > 0) { const job = this.jobs.shift()! try { const { worker, input } = job.build() - this.completed.push(worker.run(input)) + // run() is async to support an injected reviewer (H32-2). Awaiting here keeps the queue + // sequential — one job at a time — which matches the original sync behaviour and prevents + // concurrent writes to the durable store. + this.completed.push(await worker.run(input)) } catch { // A failed learning pass is non-fatal and must not stop the queue or the turn. } @@ -396,8 +423,9 @@ export class LearningQueue { get results(): readonly LearningWorkerResult[] { return this.completed } - // Drain synchronously (tests): run all queued jobs now instead of on the scheduler. - drainNow(): void { - this.drain() + // Drain all queued jobs and await completion. Tests use this to drain deterministically; + // the return type changed from void to Promise with the H32-2 async reviewer seam. + async drainNow(): Promise { + await this.drain() } } diff --git a/packages/core/src/deepagent/code-indexer.ts b/packages/core/src/deepagent/code-indexer.ts index 78053e8d..0ed573b7 100644 --- a/packages/core/src/deepagent/code-indexer.ts +++ b/packages/core/src/deepagent/code-indexer.ts @@ -95,25 +95,6 @@ const findByPath = (store: DurableKnowledgeStore, path: string): Doc | null => { return null } -// The stored `mtime_ms` for every already-indexed code_symbol node, keyed by path. Exposed for a -// filesystem-walking caller (code-index-trigger) to skip the READ + HASH of a file whose on-disk mtime -// EXACTLY equals the recorded one — the only layer where mtime avoids real I/O (§B.3 SEAM). This is an -// I/O-avoidance hint, NOT a correctness gate: an EXACT match is required (not `>=`), so a rewound or -// non-monotonic mtime (git checkout / stash pop / rebase) does NOT equal the stored value and the file -// is re-read + content-sha-gated as before. Content-sha remains the sole skip AUTHORITY for anything -// actually read. A node without a recorded mtime is simply absent from the map (⇒ always re-read). -export const indexedMtimes = (store: DurableKnowledgeStore): ReadonlyMap => { - const ds = store.documentStore - const out = new Map() - for (const ref of ds.list({ type: CODE_SYMBOL })) { - const doc = ds.get(ref.id) - if (!doc || doc.status === "rejected") continue - const mtime = doc.extensions?.mtime_ms - if (typeof mtime === "number") out.set(doc.description, mtime) - } - return out -} - // Register (create or content-gated upsert) a single file as a code_symbol node. Returns the node id // plus whether it was created/updated/unchanged. CONTENT-SHA gating avoids version bloat. export const registerFile = ( diff --git a/packages/core/src/deepagent/context-admission.ts b/packages/core/src/deepagent/context-admission.ts index 9e992ef2..d7abdddb 100644 --- a/packages/core/src/deepagent/context-admission.ts +++ b/packages/core/src/deepagent/context-admission.ts @@ -74,10 +74,7 @@ export const admitIndexRefs = ( for (const e of candidates) { if (admitted.length >= p.max_index_refs) break const eTokens = Math.ceil((e.title.length + e.summary.length) / 4) - // Skip (not break) over-budget refs: candidates are in filter order, not sorted by size, - // so a large ref must not starve smaller admittable refs behind it. Budget is only charged - // for refs actually admitted, so skipped entries leave `tokens` unchanged. - if (tokens + eTokens > p.max_estimated_tokens) continue + if (tokens + eTokens > p.max_estimated_tokens) break admitted.push(e) tokens += eTokens } diff --git a/packages/core/src/deepagent/context/config.ts b/packages/core/src/deepagent/context/config.ts index 4f9b5b5d..fb52faa1 100644 --- a/packages/core/src/deepagent/context/config.ts +++ b/packages/core/src/deepagent/context/config.ts @@ -80,3 +80,11 @@ export const resolveContextConfig = (overrides: ContextConfigOverrides = {}): Co excludeReasoning: merged.excludeReasoning, } } + +// The absolute token ceiling for a working set given a model context window. This is THE enforcement +// point referenced by the Curator: budget = floor(contextTokens * budgetFraction), and budgetFraction +// is already clamped to <= 0.5. Returns 0 for an unknown/zero context (caller then falls back). +export const workingSetBudgetTokens = (contextTokens: number, config: ContextConfig): number => { + if (!Number.isFinite(contextTokens) || contextTokens <= 0) return 0 + return Math.floor(contextTokens * config.budgetFraction) +} diff --git a/packages/core/src/deepagent/context/curator.ts b/packages/core/src/deepagent/context/curator.ts new file mode 100644 index 00000000..ac68dc19 --- /dev/null +++ b/packages/core/src/deepagent/context/curator.ts @@ -0,0 +1,122 @@ +import { Context, Effect, Layer } from "effect" +import type { DocumentStore } from "../document-store" +import { GraphQuery } from "../graph-query" +import type { ContextConfig, ContextConfigOverrides } from "./config" +import { resolveContextConfig } from "./config" +import type { Ledger } from "./ledger" +import { loadLedger } from "./ledger" +import type { WorkingSet, WorkingSetCandidate } from "./working-set" +import { assemble, anchorCandidates, ledgerRecall } from "./working-set" + +// V3.8 Appendix-A C1/C2 (Stage 2) — the Curator service. It assembles the per-turn Working Set from: +// - the session Ledger (task anchor + recall candidates), loaded from the run-scoped DocumentStore, +// - relevance recall via the SHARED GraphQuery service (keyword/token, NO embeddings — decision #3), +// - the caller-supplied near-field verbatim turns + active references, +// under the HARD 50% ceiling enforced in working-set.assemble. +// +// DEFAULT-SAFE (Phase 3 lesson): DocumentStore construction/reads throw SYNCHRONOUSLY (JSON.parse / +// readFileSync). Effect.catch recovers only typed errors, NOT defects, and catchAllCause is not in +// this build. So the "never fails into the session loop" guarantee is implemented with +// Effect.matchCauseEffect, which recovers the CAUSE (defects included). On ANY failure the Curator +// returns `undefined` (a signal to the caller to fall back to existing compaction) — it never throws. + +export type CuratorRequest = { + // The current task/step text used for relevance scoring (typically the latest user message + the + // ledger's current `next`). + readonly task: string + // Run-scoped DocumentStore holding this session's ledger. Omitted -> anchor/recall come only from + // an empty ledger (near-field still assembles). The Curator NEVER constructs a store itself. + readonly store?: DocumentStore + readonly sessionId: string + // Model context window in tokens (for the budget). 0/unknown -> Curator returns undefined (fall + // back), since a budget can't be computed. + readonly contextTokens: number + // Verbatim recent turns (already ordered oldest->newest by the caller). Each carries optional real + // token counts (C5) and an isReasoning flag so reasoning is excluded. + readonly nearField: readonly WorkingSetCandidate[] + // Active references: latest version of files/tool outputs the current task touches. + readonly references?: readonly WorkingSetCandidate[] + // Workspace path for GraphQuery's project-store union (optional). + readonly workspacePath?: string + readonly configOverrides?: ContextConfigOverrides +} + +export interface Interface { + // Build the Working Set for this turn, or undefined if unconfigured/failed (caller falls back). + readonly curate: (req: CuratorRequest) => Effect.Effect +} + +export class Service extends Context.Service()("@deepagent-code/deepagent/context/Curator") {} + +// GraphQuery recall over the ledger entries: pulls `ledger`-type hits scored by keyword/token +// similarity + graph distance, mapped to recall candidates. This is the "recall via GraphQuery" path +// (decision #3 — reuse the shared retrieval service, no parallel recall layer). Falls back to the +// in-ledger `ledgerRecall` scorer when GraphQuery returns nothing (e.g. knowledge-source unconfigured +// in a pure unit test), so recall still works over the loaded ledger. +const buildRecall = ( + graph: GraphQuery.Interface, + req: CuratorRequest, + ledger: Ledger, + config: ContextConfig, +) => + Effect.gen(function* () { + const result = yield* graph.query({ + ...(req.workspacePath ? { workspacePath: req.workspacePath } : {}), + task: req.task, + types: ["ledger"], + limitPerType: config.recallLimit, + }) + const anchorIds = new Set(anchorCandidates(ledger).map((c) => c.id)) + const hits = (result.byType["ledger"] ?? []) + .filter((h) => !anchorIds.has(h.doc.id)) + .map( + (h): WorkingSetCandidate => ({ + id: h.doc.id, + kind: "recall", + text: h.doc.body, + score: h.score, + }), + ) + if (hits.length > 0) return hits + // Fallback: score the loaded ledger locally (GraphQuery empty -> unconfigured knowledge-source). + return ledgerRecall(ledger, req.task, config) + }) + +const curateImpl = (graph: GraphQuery.Interface) => (req: CuratorRequest) => + Effect.gen(function* () { + const config = resolveContextConfig(req.configOverrides) + if (req.contextTokens <= 0) return undefined // can't budget -> fall back + + // Ledger load throws synchronously on a corrupt store; the whole gen is guarded below. + const ledger = req.store ? loadLedger(req.store, req.sessionId) : { sessionId: req.sessionId, entries: [], updatedAt: Date.now() } + const anchor = anchorCandidates(ledger) + const recall = yield* buildRecall(graph, req, ledger, config) + + const nearField = req.nearField.slice(-config.nearFieldTurns) + + return assemble({ + contextTokens: req.contextTokens, + config, + anchor, + nearField, + references: req.references ?? [], + recall, + }) + }).pipe( + // DEFAULT-SAFE: recover the CAUSE (defects from sync store throws included), never rethrow into + // the session loop. Returns undefined -> caller keeps existing compaction behavior. + Effect.matchCauseEffect({ + onFailure: () => Effect.succeed(undefined), + onSuccess: (ws) => Effect.succeed(ws), + }), + ) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const graph = yield* GraphQuery.Service + return Service.of({ curate: curateImpl(graph) }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(GraphQuery.layer)) diff --git a/packages/core/src/deepagent/context/index.ts b/packages/core/src/deepagent/context/index.ts index 887e1bd9..58811ee8 100644 --- a/packages/core/src/deepagent/context/index.ts +++ b/packages/core/src/deepagent/context/index.ts @@ -1,12 +1,12 @@ -// V3.8 Appendix-A — context-management substrate. The live members: session Ledger (C2) + Project -// Bridge (C3, cross-session handoff) + Conversation Log (C2.5) + Config knobs. The main-session context -// CURATOR bridge (Working Set / Curator / chunked Ingest / a standalone token-meter) was never wired to -// the main prompt loop — the only live symbol-graph retrieval is the IM @agent path's own -// context-builder — so that dead cluster was removed in V4.1 T2.5. If main-session code-graph retrieval -// is wanted later, design it as an explicit new feature rather than reviving this half-wired layer. +// V3.8 Appendix-A — context-management redesign (Working Set + Ledger + Bridge + Conversation Log + +// Curator + chunked ingest). Greenfield module. See each file for the C-section it implements. export * as ContextConfig from "./config" +export * as ContextTokenMeter from "./token-meter" export * as SessionLedger from "./ledger" +export * as WorkingSet from "./working-set" +export * as ContextCurator from "./curator" export * as ConversationLog from "./conversation-log" export * as ProjectBridge from "./bridge" +export * as ContextIngest from "./ingest" // V4.0.1 P1 — World State (snapshot-diff volatile facts, re-injected as a tail block, never the prefix). export * as WorldState from "./world-state" diff --git a/packages/core/src/deepagent/context/ingest.ts b/packages/core/src/deepagent/context/ingest.ts new file mode 100644 index 00000000..a19e7b37 --- /dev/null +++ b/packages/core/src/deepagent/context/ingest.ts @@ -0,0 +1,249 @@ +import { Effect } from "effect" +import { LLM, Message, type Model, type LLMClientService } from "@deepagent-code/llm" +import type { DocumentStore } from "../document-store" +import type { ContextConfig } from "./config" +import { estimate } from "./token-meter" + +// V3.8 Appendix-A C1.5 — chunked ingest. When an input (a big file / a batch / a whole book) itself +// exceeds the 50% Working Set ceiling, we NEVER widen the working set. Instead we run a separate +// map-reduce ingest that digests the big input, OUTSIDE the context, into a RETRIEVABLE memory doc: +// 1. chunk by natural structure (chapters / files / modules / time windows), each far below the +// ceiling (config.ingestChunkTokens), +// 2. map: summarize each chunk -> one memory entry with a POSITION REFERENCE (offset/line/heading), +// 3. reduce: fold the chunk summaries into a top-level memory, +// 4. land it as a retrievable artifact (a `memory`/`artifact` doc) so later questions re-query the +// relevant chunk by reference instead of re-reading the whole input. +// +// This module is the PURE pipeline (chunking + orchestration). The actual per-chunk summarization is +// an INJECTED function (`summarize`) — same shape as the A4 map-reduce segment-summarize mechanism — +// so this stays testable without an LLM: a test injects a deterministic summarizer. The 50% ceiling +// is respected structurally: chunks are sized to ingestChunkTokens and only ONE chunk is "in hand" at +// a time (the pipeline never accumulates the whole input in memory-as-context). + +export type Chunk = { + readonly index: number + readonly heading: string + readonly text: string + // Position reference back into the source for on-demand re-read (C1.5 §2 "原文位置引用"). + readonly startOffset: number + readonly endOffset: number +} + +// Split text into chunks by natural structure. Prefers markdown-style headings (# / ## ...); falls +// back to blank-line paragraphs; then packs consecutive units so each chunk is <= targetTokens. +// Never splits mid-line. Offsets are byte-in-string offsets into the original. +export const chunkByStructure = (text: string, targetTokens: number): Chunk[] => { + if (!text) return [] + const lines = text.split("\n") + // Build units: a unit starts at each heading line, otherwise groups run until the next heading. + type Unit = { heading: string; start: number; end: number } + const units: Unit[] = [] + let offset = 0 + let cur: Unit | null = null + const isHeading = (l: string) => /^#{1,6}\s+/.test(l) + for (const line of lines) { + const lineLen = line.length + 1 // +\n + if (isHeading(line) || cur === null) { + if (cur) units.push(cur) + cur = { heading: isHeading(line) ? line.replace(/^#+\s+/, "").trim() : "section", start: offset, end: offset + lineLen } + } else { + cur.end = offset + lineLen + } + offset += lineLen + } + if (cur) units.push(cur) + + // Pack units into chunks under the token budget. + const chunks: Chunk[] = [] + let buf: { heading: string; start: number; end: number; text: string } | null = null + const flush = () => { + if (!buf) return + chunks.push({ index: chunks.length, heading: buf.heading, text: buf.text, startOffset: buf.start, endOffset: buf.end }) + buf = null + } + for (const u of units) { + const utext = text.slice(u.start, u.end) + if (!buf) { + buf = { heading: u.heading, start: u.start, end: u.end, text: utext } + } else if (estimate(buf.text + utext) <= targetTokens) { + buf.text += utext + buf.end = u.end + } else { + flush() + buf = { heading: u.heading, start: u.start, end: u.end, text: utext } + } + // A single oversized unit still becomes its own chunk (can't split a line-run further here). + if (buf && estimate(buf.text) > targetTokens) flush() + } + flush() + return chunks +} + +export type ChunkSummary = { + readonly index: number + readonly heading: string + readonly summary: string + readonly startOffset: number + readonly endOffset: number +} + +// The injected summarizer: chunk text -> a short summary. Effectful shape left to the caller (sync +// here for the pure pipeline; the session-side caller can adapt an LLM call via a sync-over-async +// bridge or precompute). +export type Summarize = (chunk: Chunk) => string + +export type IngestResult = { + readonly chunkSummaries: readonly ChunkSummary[] + readonly topLevel: string + // The persisted memory doc id (when a store is provided). + readonly memoryDocId?: string +} + +// Run the ingest pipeline over a big input. `sourceName` labels the memory doc (file path / title). +// map: summarize each chunk; reduce: fold summaries into a top-level memory. When `store` is given, +// persist a position-referenced `memory` doc (retrievable artifact) and return its id. +export const ingest = (input: { + sourceName: string + text: string + config: ContextConfig + summarize: Summarize + reduce?: (summaries: readonly ChunkSummary[]) => string + store?: DocumentStore + scope?: string +}): IngestResult => { + const chunks = chunkByStructure(input.text, input.config.ingestChunkTokens) + const chunkSummaries: ChunkSummary[] = chunks.map((c) => ({ + index: c.index, + heading: c.heading, + summary: input.summarize(c), + startOffset: c.startOffset, + endOffset: c.endOffset, + })) + const topLevel = input.reduce + ? input.reduce(chunkSummaries) + : defaultReduce(input.sourceName, chunkSummaries) + + let memoryDocId: string | undefined + if (input.store) { + // The memory doc body keeps BOTH the top-level memory and each chunk's summary + position ref, so + // a later question can locate the relevant chunk and re-read only that slice of the source. + const body = JSON.stringify( + { + source: input.sourceName, + topLevel, + chunks: chunkSummaries, + }, + null, + 2, + ) + const doc = input.store.upsert({ + type: "memory", + scope: input.scope ?? "durable", + idSlug: `ingest-${input.sourceName}`, + description: `file memory: ${input.sourceName}`, + body, + tags: ["ingest", "file-memory"], + provenance: { source: "runner" }, + // memory is a KNOWLEDGE_TYPE -> requires confidence. This is derived-from-source, medium + // evidence with support = chunk count. + confidence: { evidence_strength: "medium", support_count: chunkSummaries.length }, + }) + memoryDocId = doc.id + } + + return { chunkSummaries, topLevel, ...(memoryDocId ? { memoryDocId } : {}) } +} + +const defaultReduce = (source: string, summaries: readonly ChunkSummary[]): string => + [`# Memory: ${source}`, "", ...summaries.map((s) => `- [${s.heading}] ${s.summary}`)].join("\n") + +// Re-read a specific chunk's original text from the source using a stored position reference (C1.5 +// "按引用回查那一块的原文"). Pure slice — the caller supplies the original source text. +export const rereadChunk = (sourceText: string, ref: { startOffset: number; endOffset: number }): string => + sourceText.slice(ref.startOffset, ref.endOffset) + +// --- production LLM summarizer adapter (C1.5 map step, real LLM) ----------------------------------- +// +// The pure `ingest` above takes a SYNC `Summarize` (chunk -> string) so it stays testable without an +// LLM. A real deployment must summarize each chunk with the model. That is inherently ASYNC, and the +// sync boundary of `ingest` cannot call the LLM inline. We honestly bridge the gap the same way the A4 +// map-reduce mechanism does: PRE-COMPUTE every chunk summary via the LLM (async, concurrent, bounded), +// then run the pure `ingest` with a sync lookup into the precomputed map. No sync-over-async blocking. +// +// This reuses the EXISTING LLM capability (@deepagent-code/llm LLMClient.generate — the same client the +// session-side compaction summarizer streams through), not a parallel model path. The prompt mirrors +// compaction's terse-summary style. Caps are LENIENT/configurable (maxSummaryTokens defaults high). + +export type LlmSummarizerOptions = { + readonly model: Model + // Lenient default; a chunk summary is short by design but we do not bake a tight cap. + readonly maxSummaryTokens?: number + // Bounded concurrency for the pre-summarize map (lenient default 4). + readonly concurrency?: number + // Optional instruction override; defaults to a terse map-step summary prompt. + readonly instruction?: string +} + +const DEFAULT_SUMMARY_TOKENS = 512 +const DEFAULT_INGEST_CONCURRENCY = 4 +const DEFAULT_SUMMARY_INSTRUCTION = + "Summarize the following section in 1-3 terse bullet points. Preserve exact identifiers, file paths, " + + "commands, and error strings. Output only the bullets, no preamble." + +// Summarize ONE chunk via the LLM. Effect over LLMClient.Service (provided by the caller's runtime — +// the gateway already wires LLMClient.layer). Returns the assembled assistant text. +export const summarizeChunkEffect = ( + chunk: Chunk, + opts: LlmSummarizerOptions, +): Effect.Effect => + Effect.gen(function* () { + const instruction = opts.instruction ?? DEFAULT_SUMMARY_INSTRUCTION + const prompt = `${instruction}\n\n
\n${chunk.text}\n
` + const response = yield* LLM.generate( + LLM.request({ + model: opts.model, + messages: [Message.user(prompt)], + tools: [], + generation: { maxTokens: opts.maxSummaryTokens ?? DEFAULT_SUMMARY_TOKENS }, + }), + ) + return response.text.trim() + }).pipe( + // DEFAULT-SAFE: a per-chunk LLM failure (typed error or defect) degrades to a position-referenced + // placeholder rather than failing the whole ingest — the chunk is still retrievable by heading + + // offsets. matchCauseEffect recovers the CAUSE (defects included), consistent with the module. + Effect.matchCauseEffect({ + onFailure: () => Effect.succeed(`[summary unavailable] ${chunk.heading}`), + onSuccess: (text) => Effect.succeed(text), + }), + ) + +// The Effect-returning production ingest: pre-summarize every chunk with the LLM (bounded concurrency), +// then run the PURE `ingest` with a sync lookup into the precomputed summaries. Same output shape as +// `ingest`; the only difference is the summarizer is a real model call resolved before the sync pass. +export const ingestEffect = (input: { + sourceName: string + text: string + config: ContextConfig + summarizer: LlmSummarizerOptions + reduce?: (summaries: readonly ChunkSummary[]) => string + store?: DocumentStore + scope?: string +}): Effect.Effect => + Effect.gen(function* () { + const chunks = chunkByStructure(input.text, input.config.ingestChunkTokens) + const summaries = yield* Effect.forEach(chunks, (c) => summarizeChunkEffect(c, input.summarizer), { + concurrency: input.summarizer.concurrency ?? DEFAULT_INGEST_CONCURRENCY, + }) + // Precomputed map keyed by chunk index; the sync `ingest` looks up here (no async at the boundary). + const byIndex = new Map(chunks.map((c, i) => [c.index, summaries[i]!])) + return ingest({ + sourceName: input.sourceName, + text: input.text, + config: input.config, + summarize: (c) => byIndex.get(c.index) ?? `[summary unavailable] ${c.heading}`, + ...(input.reduce ? { reduce: input.reduce } : {}), + ...(input.store ? { store: input.store } : {}), + ...(input.scope ? { scope: input.scope } : {}), + }) + }) diff --git a/packages/core/src/deepagent/context/token-meter.ts b/packages/core/src/deepagent/context/token-meter.ts new file mode 100644 index 00000000..f2979dfe --- /dev/null +++ b/packages/core/src/deepagent/context/token-meter.ts @@ -0,0 +1,67 @@ +// V3.8 Appendix-A C5 — token metering that the Curator's budgeted assembly relies on. Two rules: +// 1. REAL usage preferred: when a provider reports actual token counts, use them; only estimate when +// no real count is available. `preferReal` implements that precedence. +// 2. Better estimate: the repo-wide fallback is chars/4 (util/token.ts), which is badly wrong for +// CJK and code (App-A C5: "对中文和代码严重偏差"). `estimate` splits the text into CJK vs +// ASCII/other and applies a per-class chars-per-token ratio, so a budget computed over Chinese or +// dense code is far closer to reality. This stays additive: util/token.ts is untouched; this is +// the context-management-local upgrade the Curator uses. chars/4 remains the floor when a string +// has no CJK (ASCII ~4 chars/token is already decent). +// +// Not a tokenizer — no model BPE here (none available in-repo). It is a calibrated heuristic whose +// only job is to keep the 50%-ceiling arithmetic honest enough that a real provider count rarely +// surprises us. When the real count arrives it always wins. + +// CJK unified ideographs + common Han/Kana/Hangul ranges. Each such char is ~1 token (often <1 for +// common Han under BPE, but 1 is a safe, slightly-conservative upper estimate that avoids +// under-budgeting — the failure mode we care about is over-filling the window). +const CJK_RE = + /[ -〿぀-ヿ㐀-䶿一-鿿豈-﫿＀-￯가-힯]/u + +const ASCII_CHARS_PER_TOKEN = 4 + +export const isCJK = (ch: string): boolean => CJK_RE.test(ch) + +// Estimate tokens for a string. CJK runs are counted ~1 token/char; the rest at ~ASCII_CHARS_PER_TOKEN +// chars/token. Never negative; empty -> 0. +export const estimate = (input: string): number => { + if (!input) return 0 + let cjk = 0 + let other = 0 + for (const ch of input) { + if (CJK_RE.test(ch)) cjk++ + else other++ + } + return Math.max(0, Math.round(cjk + other / ASCII_CHARS_PER_TOKEN)) +} + +// Real provider token usage for one message/turn, if the provider reported it. All fields optional +// so a partial report still yields a best real total. +export type RealUsage = { + readonly input?: number + readonly output?: number + readonly reasoning?: number + readonly total?: number + readonly cacheRead?: number + readonly cacheWrite?: number +} + +// Collapse a RealUsage to a single token count: prefer an explicit `total`, else sum the parts. +// Returns undefined when nothing usable was reported (caller then estimates). +export const realTotal = (usage: RealUsage | undefined): number | undefined => { + if (!usage) return undefined + if (typeof usage.total === "number" && usage.total > 0) return usage.total + const parts = [usage.input, usage.output, usage.reasoning, usage.cacheRead, usage.cacheWrite].filter( + (v): v is number => typeof v === "number" && v >= 0, + ) + if (parts.length === 0) return undefined + const sum = parts.reduce((a, b) => a + b, 0) + return sum > 0 ? sum : undefined +} + +// The C5 precedence in one call: real provider count when available, otherwise the CJK/code-aware +// estimate over `text`. This is what the Curator uses to price an item. +export const preferReal = (real: RealUsage | undefined, text: string): number => { + const r = realTotal(real) + return r !== undefined ? r : estimate(text) +} diff --git a/packages/core/src/deepagent/context/working-set.ts b/packages/core/src/deepagent/context/working-set.ts new file mode 100644 index 00000000..e968d32f --- /dev/null +++ b/packages/core/src/deepagent/context/working-set.ts @@ -0,0 +1,139 @@ +import type { Ledger, LedgerEntry } from "./ledger" +import { taskAnchor, currentNext, recallCandidates } from "./ledger" +import type { ContextConfig } from "./config" +import { workingSetBudgetTokens } from "./config" +import { estimate } from "./token-meter" +import { knowledgeSimilarity } from "../document-store" + +// V3.8 Appendix-A C1 — the Working Set Curator (public axiom 1: "不换对话 → 持续专注"). Each turn the +// Curator BUILDS a budgeted working set instead of "take all history → compact when it overflows". +// Composition, filled by fixed priority until the budget is spent: +// 1. task anchor (NEVER dropped): active Goal + active Constraint (from the Ledger). +// 2. near-field: the most recent N verbatim turns (short-term memory / coherence). +// 3. active references: latest version of files/tool-results the current task touches. +// 4. relevance recall: a few Ledger entries relevant to the current step (via GraphQuery-scored +// keyword/token similarity — NO embeddings). +// 5. budget guardrail: the whole set is <= workingSetBudgetTokens(context) — a HARD 50% ceiling. +// +// Reasoning is EXCLUDED by default (C1): it is drafted + logged but not carried to the next turn. +// This module is a PURE assembler: it takes already-scored recall hits + the ledger + near-field +// items and produces a budgeted plan. It does NOT do IO — the caller (a thin Effect service) supplies +// the ledger, the recent turns, and the recall hits (obtained via GraphQuery). That keeps the 50% +// arithmetic unit-testable with no store/provider wiring. + +export type WorkingSetItemKind = "anchor" | "near_field" | "reference" | "recall" + +// A candidate for admission to the working set. `tokens` may be supplied (real provider count, C5); +// otherwise it is estimated from `text` with the CJK/code-aware estimator. +export type WorkingSetCandidate = { + readonly id: string + readonly kind: WorkingSetItemKind + readonly text: string + // If the caller has a real/measured token count, pass it — the Curator prefers it over estimate(). + readonly tokens?: number + // For recall ordering: higher first. Ignored for anchor/near_field (their own order is preserved). + readonly score?: number + // Marks reasoning-origin content so the exclude-reasoning guard can drop it (C1). Never true for + // anchor items. + readonly isReasoning?: boolean +} + +export type WorkingSetItem = WorkingSetCandidate & { readonly tokens: number } + +export type WorkingSet = { + readonly items: readonly WorkingSetItem[] + readonly tokens: number + readonly budget: number + // Candidates that did NOT fit under the ceiling — the caller routes large/over-budget inputs here + // to the C1.5 chunked-ingest path instead of growing the working set (C1.5: never widen the set). + readonly overflow: readonly WorkingSetItem[] +} + +const priceOf = (c: WorkingSetCandidate): number => + typeof c.tokens === "number" && c.tokens >= 0 ? c.tokens : estimate(c.text) + +// Assemble a budgeted working set from prioritized candidates under a HARD token ceiling. +// +// Enforcement (the 50% hard ceiling): `budget` is computed by workingSetBudgetTokens (fraction is +// pre-clamped to <= MAX_BUDGET_FRACTION in config). Items are admitted in priority order and the +// running total is asserted to NEVER exceed `budget`. The anchor is admitted first and is expected to +// be tiny; if even the anchor exceeds budget we still stop at the ceiling (return only what fits) so +// the invariant `result.tokens <= budget` ALWAYS holds — we never emit an over-budget set. +export const assemble = (input: { + contextTokens: number + config: ContextConfig + // Priority-ordered groups. anchor first, then near-field (most-recent last is fine — caller orders), + // then references, then recall (already sorted best-first by the caller / GraphQuery score). + anchor: readonly WorkingSetCandidate[] + nearField: readonly WorkingSetCandidate[] + references: readonly WorkingSetCandidate[] + recall: readonly WorkingSetCandidate[] +}): WorkingSet => { + const budget = workingSetBudgetTokens(input.contextTokens, input.config) + const admitted: WorkingSetItem[] = [] + const overflow: WorkingSetItem[] = [] + let total = 0 + + const consider = (c: WorkingSetCandidate) => { + // Reasoning is excluded from the working set by default (C1). Route it nowhere (it lives in the + // Conversation Log, not here). + if (input.config.excludeReasoning && c.isReasoning) return + const tokens = priceOf(c) + const item: WorkingSetItem = { ...c, tokens } + // HARD CEILING: admit only if it keeps the running total within budget. + if (total + tokens <= budget) { + admitted.push(item) + total += tokens + } else { + overflow.push(item) + } + } + + // Fixed priority order. Anchor first so the task never drops. + for (const c of input.anchor) consider({ ...c, kind: "anchor", isReasoning: false }) + for (const c of input.nearField) consider({ ...c, kind: "near_field" }) + for (const c of input.references) consider({ ...c, kind: "reference" }) + // recall sorted best-first + for (const c of [...input.recall].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))) consider({ ...c, kind: "recall" }) + + // Invariant assertion (belt-and-suspenders alongside config clamping): the emitted set is never + // over the ceiling. This throws only on a real bug in the arithmetic above, never on user input + // (over-budget input lands in `overflow`, not `admitted`). + if (total > budget) { + throw new Error(`working-set invariant violated: ${total} > ceiling ${budget}`) + } + + return { items: admitted, tokens: total, budget, overflow } +} + +// Build the anchor candidates from a ledger's task anchor (active goals + constraints) plus the +// current live step (`next`). These are the never-drop items (C1 §1). +export const anchorCandidates = (ledger: Ledger): WorkingSetCandidate[] => { + const out: WorkingSetCandidate[] = taskAnchor(ledger).map((e) => entryCandidate(e, "anchor")) + const next = currentNext(ledger) + if (next) out.push(entryCandidate(next, "anchor")) + return out +} + +const entryCandidate = (e: LedgerEntry, kind: WorkingSetItemKind): WorkingSetCandidate => ({ + id: e.id, + kind, + text: e.rationale ? `[${e.kind}] ${e.text} — ${e.rationale}` : `[${e.kind}] ${e.text}`, +}) + +// Score ledger recall candidates against the current task using the SAME keyword/token similarity +// primitive GraphQuery uses (knowledgeSimilarity — overlap coefficient, NO embeddings). This is the +// in-ledger recall fallback used when a full GraphQuery pass is not wired; the Curator service prefers +// GraphQuery hits and uses this only over the local ledger entries. Returns candidates sorted +// best-first, capped to config.recallLimit, excluding the anchor kinds. +export const ledgerRecall = (ledger: Ledger, task: string, config: ContextConfig): WorkingSetCandidate[] => { + const scored = recallCandidates(ledger) + .map((e) => { + const sim = task && task.length > 0 ? knowledgeSimilarity(`${e.text} ${e.rationale ?? ""}`, task) : 0 + return { entry: e, score: sim } + }) + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, config.recallLimit) + return scored.map((s) => ({ ...entryCandidate(s.entry, "recall"), score: s.score })) +} diff --git a/packages/core/src/deepagent/deepagent-bus-transport.ts b/packages/core/src/deepagent/deepagent-bus-transport.ts new file mode 100644 index 00000000..ae7a4d56 --- /dev/null +++ b/packages/core/src/deepagent/deepagent-bus-transport.ts @@ -0,0 +1,84 @@ +export * as DeepAgentBusTransport from "./deepagent-bus-transport" + +import type { DeepAgentEvent } from "./deepagent-event" + +// K40-1 (v4.0.4): pluggable transport seam for the DeepAgent Event Bus. +// +// DESIGN PRINCIPLE: the Event Bus Interface (deepagent-event-bus.ts) defines the BEHAVIOUR contract +// (publish/subscribe/ack/nack/replay/…). The Transport defines the STORAGE contract — the minimal +// primitives that any backend must implement for the bus to work. By keeping these separate: +// - The local SQLite backend (current production) and a future distributed backend (Redis Streams / +// Kafka) implement the same Transport interface. +// - Contract tests (deepagent-bus-transport.contract.test.ts) run against ANY Transport to verify +// invariants: persist idempotency, group-registry persistence, delivery ordering. +// - The bus layer composes Transport + in-memory fan-out; Transport never knows about live subscribers. +// +// This is a seam, not a full abstraction: the SQLite bus implementation still uses Drizzle directly +// for complex queries (retry scans, retention sweeps, DLQ views). Those can migrate to Transport +// methods incrementally once the seam proves stable. + +export interface ConsumerGroupRecord { + readonly groupId: string + readonly typeFilter: string | null + readonly registeredAt: number + readonly lastSeenAt: number +} + +/** + * Transport — the minimal storage abstraction the Event Bus runs on. + * + * A compliant implementation MUST satisfy: + * T1 Idempotency: persistEvent with the same idempotencyKey is a no-op (returns existing). + * T2 Ordering: findEventsSince returns events in ascending createdAt/id order. + * T3 Group-persist: registerGroup/unregisterGroup survive process restarts. + * T4 Delivery: writeDelivery is upsert-safe (concurrent writes for the same key don't corrupt). + */ +export interface Transport { + /** + * T1/T2: Durably persist an event. Idempotent on idempotencyKey. Returns the persisted event + * (the existing one when idempotency fires; the new one when this write wins). + */ + readonly persistEvent: (event: DeepAgentEvent.Event) => Promise + + /** T2: Read events of a given type (or all when type is null) with createdAt >= fromMs. */ + readonly findEventsSince: (input: { + readonly type: string | null + readonly workspaceID?: string + readonly fromMs: number + readonly toMs?: number + }) => Promise> + + /** T3: Register a consumer group durably. Idempotent. */ + readonly registerGroup: (groupId: string, typeFilter: string | null, now: number) => Promise + + /** T3: Unregister a consumer group (remove from durable registry). */ + readonly unregisterGroup: (groupId: string) => Promise + + /** T3: List all durably registered groups whose typeFilter matches the event type (or null wildcard). */ + readonly groupsForType: (eventType: string) => Promise> + + /** + * T4: Upsert a delivery record for (eventId, subscriptionGroup). + * status, attempts, lastError, nextAttemptAt — overwrites existing on conflict. + */ + readonly writeDelivery: (input: { + readonly eventId: DeepAgentEvent.ID + readonly subscriptionGroup: string + readonly status: "pending" | "delivered" | "dead" + readonly attempts: number + readonly lastError: string | null + readonly nextAttemptAt: number | null + readonly now: number + }) => Promise + + /** Read the delivery record for (eventId, subscriptionGroup), or undefined if not found. */ + readonly readDelivery: ( + eventId: DeepAgentEvent.ID, + subscriptionGroup: string, + ) => Promise<{ + status: "pending" | "delivered" | "dead" + attempts: number + lastError: string | null + nextAttemptAt: number | null + } | undefined> +} diff --git a/packages/core/src/deepagent/deepagent-event-bus.ts b/packages/core/src/deepagent/deepagent-event-bus.ts index efb72fa5..ce767806 100644 --- a/packages/core/src/deepagent/deepagent-event-bus.ts +++ b/packages/core/src/deepagent/deepagent-event-bus.ts @@ -1,10 +1,10 @@ export * as DeepAgentEventBus from "./deepagent-event-bus" import { Context, Effect, Layer, PubSub, Stream } from "effect" -import { and, asc, desc, eq, lte, lt, gt, notExists, or, sql } from "drizzle-orm" +import { and, asc, desc, eq, isNull, lte, lt, gt, notExists, or, sql } from "drizzle-orm" import { alias } from "drizzle-orm/sqlite-core" import { Database } from "../database/database" -import { DeepAgentEventDeliveryTable, DeepAgentEventDropTable, DeepAgentEventTable } from "./deepagent-event-sql" +import { DeepAgentEventDeliveryTable, DeepAgentEventDropTable, DeepAgentEventTable, DeepAgentConsumerGroupTable } from "./deepagent-event-sql" import { ApprovalQueueTable } from "./approval-queue-sql" import { DeepAgentEvent } from "./deepagent-event" import { RateLimiter } from "./rate-limiter" @@ -134,6 +134,12 @@ export interface Interface { }) => Effect.Effect /** §A3 retry scan — pending deliveries whose backoff has elapsed (Router/Scheduler drives re-delivery). */ readonly dueRetries: (now?: number) => Effect.Effect> + /** + * K40-4: real pending-delivery count per workspace — the durable backlog depth for the + * backpressure gate. Counts deepagent_event_delivery rows with status='pending' scoped + * to the given workspaceID (via the event join). Use this instead of in-flight counters. + */ + readonly pendingDeliveryCount: (workspaceID: string) => Effect.Effect /** Load a single event by id from the durable log — used by the retry pump to re-dispatch a nacked delivery. */ readonly getByID: (eventID: DeepAgentEvent.ID) => Effect.Effect /** @@ -163,6 +169,19 @@ export interface Interface { * the injected clock (deterministic in tests). A no-op that never throws — safe to call any time. */ readonly sweepPublishLimiter: (now?: number) => Effect.Effect<{ readonly prunedBuckets: number }> + /** + * K40-2 (v4.0.4): Durably register a consumer group so it receives delivery rows even when offline. + * Previously groups only existed in-memory (registered for the lifetime of a live subscribe stream). + * With durable registration, `publish` writes delivery rows for ALL registered groups — not just the + * in-memory live ones — so an offline consumer catches up via `dueRetries` + `replay` on reconnect. + * Idempotent: re-registering the same group updates `last_seen_at`. + */ + readonly registerConsumerGroup: (groupId: string, typeFilter?: string) => Effect.Effect + /** + * K40-2: Durably unregister a consumer group. After this call `publish` will no longer create delivery + * rows for this group. Live in-memory streams for this group are NOT terminated — they finish naturally. + */ + readonly unregisterConsumerGroup: (groupId: string) => Effect.Effect } export class Service extends Context.Service()("@deepagent-code/DeepAgentEventBus") {} @@ -210,12 +229,22 @@ export const layerWith = (options?: LayerOptions) => const backoffBaseMs = options?.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS const now = options?.now ?? Date.now const live = yield* PubSub.unbounded() + // K40-3 (v4.0.4): separate high-priority PubSub so critical/high events are available on a + // dedicated channel that subscribers can drain FIRST — implementing true priority preemption. + // critical/high events go to BOTH `live` AND `highPriorityLive`; normal/low go to `live` only. + // `subscribe` for grouped consumers merges both channels (highPriorityLive first), so when + // multiple events are queued, critical/high are processed before normal/low. + const highPriorityLive = yield* PubSub.unbounded() // §A4/§E2 — ONE in-memory fixed-window limiter for the whole bus, keyed per workspaceID. Only // `tryPublish` consults it; `publish` is unchanged. `now` (the injected clock) drives window // resets so tests cross a boundary deterministically. const publishLimiter = new RateLimiter.Service() - yield* Effect.addFinalizer(() => PubSub.shutdown(live)) + yield* Effect.addFinalizer(() => + Effect.all([PubSub.shutdown(live), PubSub.shutdown(highPriorityLive)], { concurrency: "unbounded" }).pipe( + Effect.asVoid, + ), + ) // §A3 at-least-once — the set of consumer groups with a live `subscribe({group})` stream, and // the type filter each declared. `publish` writes a durable `pending` delivery row for every @@ -238,15 +267,37 @@ export const layerWith = (options?: LayerOptions) => else entry.types.set(type, next) if (entry.types.size === 0) groups.delete(group) }) - // groups owed a delivery for `event`: any live group with a wildcard (null) filter or a filter - // matching the event's type. - const groupsFor = (eventType: string): ReadonlyArray => { + // K40-2: in-memory live groups (unchanged: groups registered for the current stream lifetime) + const liveGroupsFor = (eventType: string): ReadonlyArray => { const out: string[] = [] for (const [group, entry] of groups) { if (entry.types.has(null) || entry.types.has(eventType)) out.push(group) } return out } + // K40-2: durable groups from DB (groups registered via registerConsumerGroup, survive restarts). + // Returns the union of live + durable groups, deduped. An offline group that registered durably + // will receive delivery rows even though it has no live stream right now. + const groupsFor = (eventType: string): Effect.Effect> => + db + .select({ group_id: DeepAgentConsumerGroupTable.group_id }) + .from(DeepAgentConsumerGroupTable) + .where( + or(isNull(DeepAgentConsumerGroupTable.type_filter), eq(DeepAgentConsumerGroupTable.type_filter, eventType)), + ) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => { + const dbGroups = rows.map((r) => r.group_id) + const live = liveGroupsFor(eventType) + // Union: db-registered + live-only (not yet durable-registered), deduplicated. + const seen = new Set(dbGroups) + const merged = [...dbGroups] + for (const g of live) if (!seen.has(g)) merged.push(g) + return merged + }), + ) const publish: Interface["publish"] = (input) => Effect.gen(function* () { @@ -281,7 +332,8 @@ export const layerWith = (options?: LayerOptions) => // `.returning()` tells us whether the insert actually landed (a racing duplicate that slips // past the read-check above hits UNIQUE(idempotency_key) → 0 rows → not the winner). Dispatch // happens AFTER commit, so a subscriber never observes an uncommitted event. - const owed = groupsFor(event.type) + // K40-2: groupsFor is now async (queries DB for durable groups + in-memory live groups). + const owed = yield* groupsFor(event.type) // §F1 event_publish_latency_ms — wall-clock delta (injected clock) around the persist // transaction. One now() before, one after; the delta is written on the row so Observability // can build the publish-latency histogram. Cheap + additive. @@ -363,7 +415,14 @@ export const layerWith = (options?: LayerOptions) => .where(eq(DeepAgentEventTable.id, event.id)) .run() .pipe(Effect.orDie) + // K40-3: critical/high events go to BOTH channels so grouped subscribers drain the + // high-priority channel first (preempting queued normal/low events). normal/low go to + // `live` only. All events go to `live` so anonymous (non-grouped) subscribers still + // receive the complete stream regardless of priority. yield* PubSub.publish(live, event) + if (event.priority === "critical" || event.priority === "high") { + yield* PubSub.publish(highPriorityLive, event) + } return event }) @@ -391,15 +450,40 @@ export const layerWith = (options?: LayerOptions) => const filtered = Stream.fromPubSub(live).pipe( Stream.filter((event) => (input.type ? event.type === input.type : true)), ) - // A grouped subscriber declares a durable consumer group: register it for the stream's scope so - // `publish` writes `pending` delivery rows it must ack (§A3 at-least-once). Anonymous - // subscribers (no group) are pure live observers — no delivery tracking. + // Anonymous subscribers (no group) are pure live observers — no delivery tracking, no priority + // preemption needed (they see everything anyway). if (input.group == null) return filtered const group = input.group const type = input.type ?? null - return filtered.pipe( - Stream.onStart(registerGroup(group, type)), - Stream.ensuring(unregisterGroup(group, type)), + + // K40-3: grouped subscribers get a merged stream — high-priority channel first so critical/high + // events preempt queued normal/low events. Anonymous subscribers keep the simple `filtered` path. + const priorityFiltered = Stream.fromPubSub(highPriorityLive).pipe( + Stream.filter((event) => (input.type ? event.type === input.type : true)), + ) + // Merge: highPriorityLive items are pulled first; live provides the rest. Both are filtered. + const mergedFiltered = Stream.merge(priorityFiltered, filtered, { haltStrategy: "both" }) + + // K40-2: update last_seen_at in DB when a group stream starts/ends so a future sweep can + // prune durably-registered groups that have been offline indefinitely. + const touchLastSeen = db + .update(DeepAgentConsumerGroupTable) + .set({ last_seen_at: now() }) + .where(eq(DeepAgentConsumerGroupTable.group_id, group)) + .run() + .pipe(Effect.orDie, Effect.asVoid) + + return mergedFiltered.pipe( + Stream.onStart( + Effect.all([registerGroup(group, type), touchLastSeen], { concurrency: "unbounded" }).pipe( + Effect.asVoid, + ), + ), + Stream.ensuring( + Effect.all([unregisterGroup(group, type), touchLastSeen], { concurrency: "unbounded" }).pipe( + Effect.asVoid, + ), + ), ) } @@ -652,6 +736,24 @@ export const layerWith = (options?: LayerOptions) => return rows.map(trackerOf) }) + // K40-4: real durable backlog depth per workspace — count pending delivery rows whose source event + // belongs to the given workspace. This is the authoritative backpressure signal: it reflects the + // number of events that are owed to at least one consumer group but not yet acked. Uses the existing + // `deepagent_event_workspace_created_idx` index on (workspace_id, created_at) for the join filter. + const pendingDeliveryCount: Interface["pendingDeliveryCount"] = (workspaceID) => + db + .select({ count: sql`count(*)` }) + .from(DeepAgentEventDeliveryTable) + .innerJoin(DeepAgentEventTable, eq(DeepAgentEventDeliveryTable.event_id, DeepAgentEventTable.id)) + .where( + and( + eq(DeepAgentEventDeliveryTable.status, "pending"), + eq(DeepAgentEventTable.workspace_id, workspaceID), + ), + ) + .get() + .pipe(Effect.orDie, Effect.map((row) => row?.count ?? 0)) + const getByID: Interface["getByID"] = (eventID) => db .select() @@ -755,6 +857,28 @@ export const layerWith = (options?: LayerOptions) => const sweepPublishLimiter: Interface["sweepPublishLimiter"] = (nowArg) => Effect.sync(() => ({ prunedBuckets: publishLimiter.sweep(nowArg ?? now()) })) + // K40-2: durable consumer group registration — persists group identity so publish writes delivery + // rows for offline groups too. Both methods are idempotent; registerConsumerGroup upserts. + const registerConsumerGroup: Interface["registerConsumerGroup"] = (groupId, typeFilter) => { + const at = now() + return db + .insert(DeepAgentConsumerGroupTable) + .values([{ group_id: groupId, type_filter: typeFilter ?? null, registered_at: at, last_seen_at: at }]) + .onConflictDoUpdate({ + target: DeepAgentConsumerGroupTable.group_id, + set: { type_filter: typeFilter ?? null, last_seen_at: at }, + }) + .run() + .pipe(Effect.orDie, Effect.asVoid) + } + + const unregisterConsumerGroup: Interface["unregisterConsumerGroup"] = (groupId) => + db + .delete(DeepAgentConsumerGroupTable) + .where(eq(DeepAgentConsumerGroupTable.group_id, groupId)) + .run() + .pipe(Effect.orDie, Effect.asVoid) + return Service.of({ publish, tryPublish, @@ -766,9 +890,12 @@ export const layerWith = (options?: LayerOptions) => deadLetters, recordDrop, dueRetries, + pendingDeliveryCount, getByID, sweep, sweepPublishLimiter, + registerConsumerGroup, + unregisterConsumerGroup, }) }), ) diff --git a/packages/core/src/deepagent/deepagent-event-sql.ts b/packages/core/src/deepagent/deepagent-event-sql.ts index 08663c82..2ff69ec4 100644 --- a/packages/core/src/deepagent/deepagent-event-sql.ts +++ b/packages/core/src/deepagent/deepagent-event-sql.ts @@ -68,6 +68,26 @@ export const DeepAgentEventDeliveryTable = sqliteTable( ], ) +// §K40-2 consumer group registry — durable consumer identity so offline/never-live groups receive +// delivery rows and can resume from their last offset on reconnect. A consumer group REGISTERS before +// (or independently of) its live stream; `publish` creates delivery rows for ALL registered groups +// whose type filter matches, not just the in-memory live ones. `last_seen_at` tracks liveness so a +// maintenance sweep can prune groups that have been offline for too long (not yet wired; placeholder). +export const DeepAgentConsumerGroupTable = sqliteTable( + "deepagent_consumer_group", + { + group_id: text().primaryKey(), + // null = wildcard (all event types); non-null = subscribe to one type only. + type_filter: text(), + registered_at: integer().notNull(), + // Updated on every live subscribe/unsubscribe so a sweep can distinguish stale registrations. + last_seen_at: integer().notNull(), + }, + (table) => [ + // fast lookup: which groups are registered for a given event type? + index("deepagent_consumer_group_type_idx").on(table.type_filter), + ], +) // §A4 event_dropped — the durable DROP LOG. One append-only row per event the router shed (a §A4 // backpressure drop), so Observability can aggregate `event_dropped_total` by reason exactly the way // `dlq_events_total` aggregates dead deliveries. Kept SEPARATE from the delivery table (a drop is not a diff --git a/packages/core/src/deepagent/document-store.ts b/packages/core/src/deepagent/document-store.ts index e66eee41..abfaab93 100644 --- a/packages/core/src/deepagent/document-store.ts +++ b/packages/core/src/deepagent/document-store.ts @@ -50,21 +50,11 @@ export type DocType = // for integrity (INV-2). body carries path/language/symbol/signature; an optional content sha in // extensions is for change-detection only, never identity/dedup. The lightweight indexer that // registers these nodes is Phase 3's concern — this union entry is the only Phase-0 change. - // version-bloat tradeoff (v3.8.1 §B.3): upsert()/update() bump version+1 and write a supersede link - // on every fingerprint change (INV-4, append-only). A frequently-edited code base makes code_symbol - // version files grow linearly. - // ⚠ T4.2 EVALUATION (V4.1): bloat is now bounded on TWO layers before it reaches here — - // (1) content-sha gating in code-indexer.registerFile writes ZERO new versions for an unchanged - // file (a re-index of an unchanged tree bumps nothing), and - // (2) T4.1's mtime gate in code-index-trigger skips the read+hash of an mtime-unchanged file, so - // an unchanged file never even reaches registerFile. - // So a version is created ONLY on a genuine content change — which is semantically correct - // versioning, one version per real edit. The residual (a single file edited many times across a - // long session accumulates that many versions) is low-severity derived data. DECISION: do NOT relax - // the append-only invariant for code_symbol here — the in-place-overwrite option would carve a - // special case into a load-bearing store invariant (INV-4) for a bounded, cosmetic cost. If disk - // growth ever becomes real, prefer a periodic retention sweep of superseded code_symbol versions - // (external to the store's write path) over relaxing append-only. Left as a marker, not relaxed. + // ⚠ Phase 3 TODO (v3.8.1 §B.3 version-bloat tradeoff): upsert()/update() bump version+1 and write + // a supersede link on every fingerprint change (INV-4, append-only). A frequently-edited code + // base makes code_symbol version files grow linearly. Phase 3's indexer must decide the mitigation + // (mtime-batched rate-limited rebuild, or relax append-only to in-place overwrite for code_symbol + // since code nodes are derived data with no audit value). NOT relaxed here — left as a marker. | "code_symbol" // ledger (v3.8.0 App-A §C2 Session Ledger): the session's structured, incrementally-maintained // authoritative fact ledger (entries {kind: goal|constraint|decision|done|open|next|artifact, @@ -502,7 +492,6 @@ export class DocumentStore { const out: { rel: LinkRel; from: DocRef }[] = [] for (const [, versions] of this.docs) { const latest = versions.get(Math.max(...versions.keys()))! - if (latest.scope === "sealed") continue // INV-7: sealed never surfaced (mirror list()) for (const l of latest.links) if (l.to === id) out.push({ rel: l.rel, from: toRef(latest) }) } return out @@ -538,8 +527,7 @@ export class DocumentStore { if (!rels.includes(l.rel) || seen.has(l.to)) continue seen.add(l.to) const td = this.get(l.to) - if (td && td.scope !== "sealed") { - // INV-7: a sealed doc is never surfaced via a graph edge, nor traversed through (mirror list()). + if (td) { result.push(toRef(td)) next.push(l.to) } @@ -586,14 +574,8 @@ export class DocumentStore { const typeDir = path.join(docsDir, entry.name) for (const file of readdirSync(typeDir)) { if (!file.endsWith(".json")) continue - // A truncated/partial-write .json doc must not brick store construction (the files are the - // truth, but one corrupt file is not the whole truth). Skip it and index the rest. - try { - const doc = JSON.parse(readFileSync(path.join(typeDir, file), "utf8")) as Doc - this.indexDoc(doc) - } catch (error) { - console.warn(`deepagent document-store: skipping unreadable doc file ${path.join(typeDir, file)}`, error) - } + const doc = JSON.parse(readFileSync(path.join(typeDir, file), "utf8")) as Doc + this.indexDoc(doc) } } } diff --git a/packages/core/src/deepagent/durable-knowledge-store.ts b/packages/core/src/deepagent/durable-knowledge-store.ts index 081fcc55..ab364124 100644 Binary files a/packages/core/src/deepagent/durable-knowledge-store.ts and b/packages/core/src/deepagent/durable-knowledge-store.ts differ diff --git a/packages/core/src/deepagent/graph-query.ts b/packages/core/src/deepagent/graph-query.ts index 950c98d3..833369ed 100644 --- a/packages/core/src/deepagent/graph-query.ts +++ b/packages/core/src/deepagent/graph-query.ts @@ -131,12 +131,6 @@ const collectFromStore = ( const consider = (id: string, distance: number): boolean => { const doc = ds.get(id) if (!doc) return false - // INV-7: sealed docs never surface (list()/neighbors()/getRefsIn() all skip them). `consider` is also - // reached from the EXPLICIT-seed frontier (caller-supplied ids resolved via raw get()), so the seal - // filter must live HERE too — otherwise a sealed seed id leaks its body into graph-query / wiki - // traversal results. Mirrors the list() exclusion; latent today (no writer emits scope:"sealed") but - // this closes the accessor before any sealed-writer ships. - if (doc.scope === "sealed") return false const existing = found.get(id) if (existing && existing.distance <= distance) return false found.set(id, { doc, distance }) diff --git a/packages/core/src/deepagent/hooks.ts b/packages/core/src/deepagent/hooks.ts index 33d970d2..e6768a72 100644 --- a/packages/core/src/deepagent/hooks.ts +++ b/packages/core/src/deepagent/hooks.ts @@ -59,55 +59,51 @@ export const stopHookGate = (): HookHandler => (e) => { : { decision: "block", blockReason: "required validations were not run; run them before finalizing" } } -// U1 PlanController soft gate (wired into before_tool_use). While the plan latch is stale, MUTATING -// tools (write/edit/patch/shell) are soft-blocked so the model must update the plan before changing -// more files; READ/diagnosis tools always pass (otherwise a stale plan could never be -// repaired). Lightweight modes (general/direct) only WARN — ordinary tasks are never slowed -// (docs/38 §9). The caller supplies planStale, isMutating and lightweight in the payload. -// -// U9 hard gate (high+): even with a fresh plan, a mutating tool must be BOUND to an active step. -// hardGateMissBlocks=true (xhigh/max/ultra) -> block on a missing active step; false (high) -> warn. -// The payload carries hardGate, hasActiveStep, hardGateMissBlocks. +// U1 PlanController soft gate (wired into before_tool_use). It NUDGES the model to keep its plan in +// sync but MUST NEVER deny a tool its execution. The caller supplies planStale, staleReason, +// isMutating, hardGate, hasActiveStep and planExists in the payload. // // DESIGN (aligned with codex core/src/exec_policy.rs render_decision_for_unmatched_command): command // safety classification and plan bookkeeping are ORTHOGONAL to whether a tool may run. In codex the // "is this a known-safe command" check only decides auto-approve-vs-prompt; a command that is NOT // known-safe is at worst prompted, and is Forbidden ONLY when it is genuinely dangerous AND the user // disabled prompts. Staleness of the plan ledger is not a safety property, so — like codex — it must -// never REJECT execution. Our previous code coupled the two: a mutating tool on a stale plan was -// hard-blocked in high+ mode, which deadlocked a model that did not repair the plan (observed: 280 -// consecutive blocked bash calls, and a read-only `ssh/docker exec` probe misclassified as mutating -// then denied outright). This gate now downgrades EVERY plan-ledger condition to a WARN (the tool -// runs, a reminder is attached), so plan state can nudge but can never deny the agent its tools. +// never REJECT execution. // -// The two remaining honest signals differ only in wording: -// - staleReason === "user_appended": a new user message MIGHT change intent — nudge to re-align. -// - graceRelease === true: repeated stale blocks with no forward progress (runtime-driven counter). -// Both warn; neither blocks. +// WHY THIS IS NOW WARN-ONLY (the recurring deadlock, fixed for real this time): +// three prior fixes (1783c9d6, 7bc8bed8, db5e64e6) each neutered the STALE layer but left the U9 +// per-step-binding layer as a hard BLOCK. Empirically that binding block was the live deadlock — +// across 68 real sessions it produced 677 hard blocks (530 on bash), 49/68 sessions hit it, and the +// worst session had 120 consecutive commands rejected because NO plan was ever bound at session start. +// Its "grace release" was non-sticky: the counter reset to 0 on every tool that got through, so the +// pattern oscillated block-block-block-pass and denied ~75% of mutating calls indefinitely — including +// ssh/docker-exec probes the lexical classifier can only see as "mutating". A workflow-discipline gate +// must not have a blast radius like that. Every plan-ledger condition is now a WARN: the tool runs, a +// reminder rides along, and plan state can nudge but can never deny the agent its tools. If plan +// discipline needs to be ENFORCED, that belongs at finalization (stopHookGate), not at every tool call. export const planGate = (): HookHandler => (e) => { if (e.name !== "before_tool_use") return { decision: "continue" } if (e.payload["isMutating"] !== true) return { decision: "allow" } - // U1 soft layer: stale plan → WARN only (never block). Reality changed / a user message arrived is - // a reason to re-sync the plan, not a reason to deny work. + // U1 soft layer: stale plan → WARN only (never block). Reality changed is a reason to re-sync the + // plan, not a reason to deny work. if (e.payload["planStale"] === true) { - const reason = "the plan is stale (reality changed); review it and update the `plan` tool to resync — this action still proceeds" + const reason = + "the plan is stale (reality changed); review it and update the `plan` tool to resync — this action still proceeds" const userAppendedReason = "a new user message arrived; your plan may no longer match the request — review it and update the `plan` tool if the goal changed" if (e.payload["staleReason"] === "user_appended") return { decision: "warn", blockReason: userAppendedReason } return { decision: "warn", blockReason: reason } } - // U9 hard layer: per-step binding (high+ only; lightweight never reaches here with hardGate set). A - // mutating tool under a strict hard gate must be bound to an active step. This is a workflow- - // discipline gate, not a safety gate, so it MUST also have a runtime-driven release: if the gate has - // already blocked this many times with no forward progress (graceRelease), stop blocking and let the - // tool through with a reminder — otherwise a model that never marks a step active would be - // permanently denied its tools (the same deadlock class the stale layer just fixed). - if (e.payload["hardGate"] === true && e.payload["hasActiveStep"] !== true) { - const reason = - "no active plan step is bound to this edit; mark the step you are working on active via the plan tool" - if (e.payload["hardGateMissBlocks"] === true && e.payload["graceRelease"] !== true) - return { decision: "block", blockReason: reason } - return { decision: "warn", blockReason: reason } + // U9 per-step binding: nudge only, and ONLY when a plan actually exists. A run that never created a + // plan (planExists !== true) is not missing an "active step" — there is no plan to bind to — so it + // must pass silently rather than be nagged (this also mirrors stopHookGate's planExists guard, which + // the old hard-block path was missing). Under the hard gate WITH a plan present, a mutating tool that + // is not bound to an active step gets a reminder, never a block. + if (e.payload["hardGate"] === true && e.payload["planExists"] === true && e.payload["hasActiveStep"] !== true) { + return { + decision: "warn", + blockReason: "no active plan step is bound to this edit; mark the step you are working on active via the plan tool", + } } return { decision: "allow" } } diff --git a/packages/core/src/deepagent/knowledge-source.ts b/packages/core/src/deepagent/knowledge-source.ts index 99314c85..2bf841a2 100644 --- a/packages/core/src/deepagent/knowledge-source.ts +++ b/packages/core/src/deepagent/knowledge-source.ts @@ -6,7 +6,7 @@ import { statusToApproval, type ScoredDoc, } from "./durable-knowledge-store" -import type { DocType } from "./document-store" +import type { DocType, DocumentStore } from "./document-store" import { DeepAgentCodeHome } from "./workspace" import { EnvironmentFactAdoption } from "./environment-fact-adoption" @@ -22,11 +22,17 @@ import { EnvironmentFactAdoption } from "./environment-fact-adoption" let baseDir: string | null = null let userGlobalCache: DurableKnowledgeStore | null = null const projectCache = new Map() +// H32-1: optional shared DocumentStore injected by the gateway so knowledge operations participate +// in the same CAS/SSOT instance as plan/session docs. When null, each store creates its own +// DocumentStore (existing behaviour, backward-compatible). +let sharedDocumentStore: DocumentStore | null = null // Configure the durable knowledge base dir (the gateway calls this alongside SessionState/MemoryStore // configure, from the injected baseDir — never a self-resolved home). -export const configure = (dir: string): void => { +// H32-1: optional sharedStore accepted; passed through to openUserGlobalStore/openProjectStore. +export const configure = (dir: string, sharedStore?: DocumentStore): void => { baseDir = dir + sharedDocumentStore = sharedStore ?? null userGlobalCache = null projectCache.clear() } @@ -40,6 +46,7 @@ export const isConfigured = (): boolean => baseDir !== null // ever configures forward). export const reset = (): void => { baseDir = null + sharedDocumentStore = null userGlobalCache = null projectCache.clear() } @@ -56,7 +63,7 @@ const ensureBase = (): string => { } const userGlobalStore = (): DurableKnowledgeStore => { - if (!userGlobalCache) userGlobalCache = openUserGlobalStore(ensureBase()) + if (!userGlobalCache) userGlobalCache = openUserGlobalStore(ensureBase(), sharedDocumentStore ?? undefined) return userGlobalCache } @@ -64,7 +71,7 @@ const projectStore = (workspacePath: string): DurableKnowledgeStore => { const pid = projectIdForWorkspace(workspacePath) let store = projectCache.get(pid) if (!store) { - store = openProjectStore(ensureBase(), workspacePath) + store = openProjectStore(ensureBase(), workspacePath, sharedDocumentStore ?? undefined) projectCache.set(pid, store) } return store diff --git a/packages/core/src/deepagent/orchestration.ts b/packages/core/src/deepagent/orchestration.ts index 36dc7c63..4b2727c5 100644 --- a/packages/core/src/deepagent/orchestration.ts +++ b/packages/core/src/deepagent/orchestration.ts @@ -270,14 +270,7 @@ export const decideFanout = (input: { * OFF and the section only tells the agent to fan out on explicit user request; at higher tiers it * gives the full fan-out judgment. Returns `null` when there is nothing worth injecting. */ -// PROMPT-CACHE NOTE: this returns the STABLE, generic orchestration guidance only — it is a pure -// function of `mode`, so it stays byte-identical across a session and can live in the cached system -// prefix. The per-turn fan-out VERDICT (concrete researcher/reviewer counts derived from this turn's -// task complexity) is deliberately NOT rendered here anymore; it changes turn-to-turn and would bust -// the prefix. The DeepAgent path renders that verdict via prompt-policy.ts `buildVolatileRoundContext` -// and appends it after the cache breakpoint. The non-DeepAgent path (session/system.ts) may still -// pass no decision and get just this stable guidance. -export const buildOrchestrationSection = (mode: AgentMode): string | null => { +export const buildOrchestrationSection = (mode: AgentMode, decision?: FanoutDecision): string | null => { const tier = tierForMode(mode) const votes = reviewerVotesForMode(mode) const header = "# 多-Agent 编排 (multi-agent orchestration)" @@ -292,6 +285,28 @@ export const buildOrchestrationSection = (mode: AgentMode): string | null => { ].join("\n") } + // §5b: when a runtime fan-out DECISION is supplied (computed by `decideFanout` from this turn's + // ComplexitySignals), the guidance stops being generic and states the concrete, task-specific + // recommendation. This is ADVISORY — the model still issues the `task` calls itself — but the + // numbers are now the scheduler's actual verdict for THIS task, not a static suggestion. The + // HARD ceiling remains the §5a semaphore, which clamps real concurrency in code regardless. + const decisionLines: string[] = [] + if (decision) { + if (!decision.orchestrate) { + decisionLines.push( + "", + `本轮调度判定(基于当前任务复杂度):不建议扇出(level=${decision.level},tier=${decision.tier},complexity=${decision.complexity})。本体直接完成,除非用户明确要求深入/多角度。`, + ) + } else { + decisionLines.push( + "", + `本轮调度判定(基于当前任务复杂度,level=${decision.level}):建议扇出约 ${decision.researchers} 个 researcher` + + (decision.reviewers > 0 ? ` + ${decision.reviewers} 个 reviewer` : "") + + `;单轮并行上限 ${decision.maxConcurrency}(代码层已按此硬限流,超发会自动排队)。`, + ) + } + } + const lines = [ header, "", @@ -306,7 +321,8 @@ export const buildOrchestrationSection = (mode: AgentMode): string | null => { "抑制信号(命中则本体做,禁止过度编排):单文件;机制已明确;纯机械改动(改名/typo/格式);用户要求快速/直接。", "", "关键判定(reviewer 的 verdict、研究结果的合并)走结构化结果:调 `task` 时传 `output_schema`(reviewer→ReviewResult,researcher→ResearchResult),不要依赖散文解析。", - `扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。本轮的具体扇出建议数见对话末尾 。`, + `扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${decision?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。`, + ...decisionLines, ] if (mode === "ultra") { lines.push("当前为 ultra:默认倾向编排并可多轮迭代。") diff --git a/packages/core/src/deepagent/round-state.ts b/packages/core/src/deepagent/round-state.ts index 88418453..f3e05ba4 100644 --- a/packages/core/src/deepagent/round-state.ts +++ b/packages/core/src/deepagent/round-state.ts @@ -88,7 +88,26 @@ const stageForDecision = (decision: RoundDecision, current: ActivationStage): Ac } } +// Identity of a candidate's evidence: same round + status + the same per-command exit outcomes. Keyed +// on exit_code (not output text) for the same reason validationFingerprint is — output carries volatile +// noise (durations/timestamps) that must not make identical evidence look distinct. +const candidateEvidenceKey = (c: CandidateRef): string => + `${c.round}|${c.status}|${[...c.validations].map((v) => `${v.command}=${v.exit_code}`).sort().join(",")}` + export const addCandidate = (state: RoundState, candidate: CandidateRef): RoundState => { + // STALE-REHARVEST DEDUPE (single append site; covers BOTH the request-prep path and the micro-round + // driver path, which used to bypass the request.ts fingerprint guard). extractValidationResults + // re-scans the whole transcript every turn, so the same early validation result is re-recorded as a + // "new" candidate each round; addCandidate previously appended unconditionally, so after N rounds the + // list held N identical candidates and every candidate-walker (collectValidationFailureText, review) + // emitted the same block N times. If the incoming candidate is evidence-identical to the LAST one, + // skip the append — a genuinely new attempt (new round, changed exit outcome, or different status) + // has a different key and still appends. Best-candidate is recomputed from the retained set, so a + // dropped duplicate can never change it. + const last = state.candidates[state.candidates.length - 1] + if (last && candidateEvidenceKey(last) === candidateEvidenceKey(candidate)) { + return state + } const candidates = [...state.candidates, candidate] const best = candidate.status === "validated" && diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts index fd5fa491..5c839fb8 100644 --- a/packages/core/src/deepagent/session-state.ts +++ b/packages/core/src/deepagent/session-state.ts @@ -55,6 +55,11 @@ export type SessionRunState = { // update. The SEMANTIC primary trigger for the progress nudge ("a step probably just finished"). // Reset with the counter on a real status change. validationPassedSinceReport: boolean + // Round-context: fingerprints (command + " " + exit_code) of validation failures the model has + // acknowledged as false positives or already-handled. Matching failures are suppressed (not + // re-injected next round). Empty set = no change in behaviour. Cleared when a genuinely new + // result for the same command arrives, so real regressions are never silently swallowed. + suppressedFingerprints: string[] // V3.9 §C: whether this conversation has EXPLICITLY toggled the Expert Panel "armed" state from the // chat dialog. `null` = never toggled → the effective armed state falls back to the global // `expertPanelDefault` setting (resolved server-side, so the UI reflects the server default without a @@ -127,6 +132,7 @@ export const getOrCreate = (sessionId: string, mode: AgentMode): SessionRunState planLatch: initialPlanLatch(), mutationsSinceReport: 0, validationPassedSinceReport: false, + suppressedFingerprints: [], panelArmed: null, panelRounds: null, activeGoal: null, @@ -297,6 +303,32 @@ export const mutationsSinceReport = (sessionId: string): number => sessions.get( export const validationPassedSinceReport = (sessionId: string): boolean => sessions.get(sessionId)?.validationPassedSinceReport ?? false +// Round-context suppression helpers (v4.0.4) +export const suppressFingerprint = (sessionId: string, fingerprint: string): void => { + const state = sessions.get(sessionId) + if (!state || state.suppressedFingerprints.includes(fingerprint)) return + state.suppressedFingerprints = [...state.suppressedFingerprints, fingerprint] + saveToDisk() +} +export const unsuppressFingerprint = (sessionId: string, fingerprint: string): void => { + const state = sessions.get(sessionId) + if (!state) return + const next = state.suppressedFingerprints.filter((f) => f !== fingerprint) + if (next.length === state.suppressedFingerprints.length) return + state.suppressedFingerprints = next + saveToDisk() +} +export const isFingerprintSuppressed = (sessionId: string, fingerprint: string): boolean => + sessions.get(sessionId)?.suppressedFingerprints.includes(fingerprint) ?? false +export const clearSuppressedFingerprints = (sessionId: string): void => { + const state = sessions.get(sessionId) + if (!state || state.suppressedFingerprints.length === 0) return + state.suppressedFingerprints = [] + saveToDisk() +} +export const getSuppressedFingerprints = (sessionId: string): readonly string[] => + sessions.get(sessionId)?.suppressedFingerprints ?? [] + // U10 / P2-E: a compact summary of the latest validation run, used as step evidence when a step // moves to `done`. Null when nothing has been validated yet. export const lastValidationSummary = (sessionId: string): string | null => { @@ -425,6 +457,8 @@ function normalizeState(state: SessionRunState): SessionRunState { // Backfill: sessions persisted before U10 have no counter on disk. mutationsSinceReport: state.mutationsSinceReport ?? 0, validationPassedSinceReport: state.validationPassedSinceReport ?? false, + // Backfill: sessions persisted before round-context suppression feature have no set on disk. + suppressedFingerprints: state.suppressedFingerprints ?? [], // Backfill: sessions persisted before V3.9 §C/§D have neither slot on disk. panelArmed backfills to // null (= not explicitly toggled → follows the global default), NOT false. panelArmed: state.panelArmed ?? null, diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index a749e5b9..40a5dd80 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -10,23 +10,16 @@ import { makeGlobalNode } from "./effect/app-node" const app = "deepagent-code" const legacyData = path.join(xdgData!, app) -// Pre-unification global-config location (XDG config, e.g. ~/.config/deepagent-code). We no longer -// treat this as the config root — user-editable config now lives at the data root (~/.deepagent/code), -// alongside the db/auth/state, in the style of ~/.claude and ~/.codex. This constant is kept only so -// migrateLegacyConfig() can relocate anything left behind here. -const legacyConfig = path.join(xdgConfig!, app) +const config = path.join(xdgConfig!, app) const tmp = path.join(os.tmpdir(), app) // P2-F single storage-root source: delegate to the shared pure resolver (global-path.ts) so the // control-plane resolver (deepagent/workspace.ts) computes the identical root for every env. const homePath = () => resolveHomeBase(process.env) const dataPath = () => resolveDataPath(process.env) -// Config now shares the data root: user-editable config (config.jsonc, themes/, plugins/…) lives -// directly under ~/.deepagent/code so everything DeepAgent owns is under one visible directory. -const configPath = () => dataPath() const cachePath = () => path.join(dataPath(), "cache") const statePath = () => path.join(dataPath(), "state") -const overrides: { log?: string; config?: string } = {} +const overrides: { log?: string } = {} const paths = { get home() { @@ -50,12 +43,7 @@ const paths = { get cache() { return cachePath() }, - get config() { - return overrides.config ?? configPath() - }, - set config(value: string) { - overrides.config = value - }, + config, get state() { return statePath() }, @@ -111,29 +99,7 @@ async function migrateLegacyData() { ) } -// Relocate the pre-unification global config (~/.config/deepagent-code) into the data root so -// existing users keep their providers/plugins/themes. Non-destructive and idempotent: files that -// already exist at the destination are never overwritten (force:false), and the legacy dir is left -// in place — matching migrateLegacyData. The canonical rename (deepagent-code.jsonc -> config.jsonc) -// is handled by the config layer once the file is at the data root. -async function migrateLegacyConfig() { - if (path.resolve(legacyConfig) === path.resolve(Path.config)) return - const entries = await fs.readdir(legacyConfig, { withFileTypes: true }).catch(() => []) - if (entries.length === 0) return - await fs.mkdir(Path.config, { recursive: true }) - await Promise.all( - entries.map((entry) => - fs.cp(path.join(legacyConfig, entry.name), path.join(Path.config, entry.name), { - recursive: true, - force: false, - errorOnExist: false, - }), - ), - ) -} - await migrateLegacyData() -await migrateLegacyConfig() await Promise.all([ fs.mkdir(Path.data, { recursive: true }), diff --git a/packages/core/src/im/id.ts b/packages/core/src/im/id.ts index 702ec237..8694bc63 100644 --- a/packages/core/src/im/id.ts +++ b/packages/core/src/im/id.ts @@ -25,13 +25,3 @@ export const MessageID = Schema.String.check(Schema.isStartsWith("imsg_")).pipe( })), ) export type MessageID = typeof MessageID.Type - -// V4.0 §B3/§B4 — IM file attachment id. Distinct prefix ("ima_") so an attachment id can never be -// mistaken for a group/member/message id in a shared code path. -export const AttachmentID = Schema.String.check(Schema.isStartsWith("ima_")).pipe( - Schema.brand("IM.Attachment.ID"), - withStatics((schema) => ({ - create: () => schema.make("ima_" + Identifier.ascending()), - })), -) -export type AttachmentID = typeof AttachmentID.Type diff --git a/packages/core/src/im/repository.ts b/packages/core/src/im/repository.ts index 596ef4e9..28f1a2be 100644 --- a/packages/core/src/im/repository.ts +++ b/packages/core/src/im/repository.ts @@ -1,8 +1,8 @@ import { Context, Effect, Layer, Schema } from "effect" -import { and, asc, desc, eq, gt, isNull, like, lt, or, sql } from "drizzle-orm" +import { and, desc, eq, isNull, lt } from "drizzle-orm" import { Database } from "../database/database" import * as IMID from "./id" -import { AttachmentTable, GroupTable, MemberTable, MessageTable, GroupType, MemberType, MemberRole, SenderType, MessageType, MessageMetadata } from "./sql" +import { GroupTable, MemberTable, MessageTable, GroupType, MemberType, MemberRole, SenderType, MessageType, MessageMetadata } from "./sql" // Repository errors export class IMRepositoryError extends Schema.ErrorClass("IMRepositoryError")({ @@ -59,94 +59,6 @@ export const MessagePage = Schema.Struct({ }) export type MessagePage = typeof MessagePage.Type -export const IMAttachment = Schema.Struct({ - id: Schema.String, - workspaceID: Schema.String, - projectID: Schema.NullOr(Schema.String), - groupID: Schema.NullOr(Schema.String), - messageID: Schema.NullOr(Schema.String), - uploadedBy: Schema.String, - storagePath: Schema.String, - filename: Schema.String, - mime: Schema.String, - sizeBytes: Schema.Number, - checksum: Schema.String, - createdAt: Schema.Number, - deletedAt: Schema.NullOr(Schema.Number), -}) -export type IMAttachment = typeof IMAttachment.Type - -// §B3 composite (created_at, id) keyset cursor for ASC-ordered scans (thread + search). Encoded as -// `_` so the tie-break is stable when many rows share a millisecond. Parsing is total: -// a malformed cursor yields `undefined` (start from the beginning) rather than throwing — matching the -// defensive posture of listMessages' cursor parsing. -export interface CompositeCursor { - readonly createdAt: number - readonly id: string -} -export const encodeCompositeCursor = (createdAt: number, id: string): string => `${createdAt}_${id}` -export const parseCompositeCursor = (cursor: string | undefined): CompositeCursor | undefined => { - if (!cursor) return undefined - const sep = cursor.indexOf("_") - if (sep <= 0) return undefined - const createdAt = parseInt(cursor.slice(0, sep), 10) - const id = cursor.slice(sep + 1) - if (isNaN(createdAt) || createdAt < 0 || id.length === 0) return undefined - return { createdAt, id } -} - -// Escape LIKE wildcards in the fallback search so a user query containing % or _ is matched literally. -// Paired with an ESCAPE clause at the query site is ideal, but SQLite treats a backslash as an ordinary -// char by default; drizzle's `like` has no ESCAPE hook, so we neutralize the metacharacters by -// stripping them — acceptable for the degraded LIKE fallback (FTS5 is the primary path). -const escapeLike = (q: string): string => q.replace(/[%_\\]/g, "") - -// Escape an arbitrary user query into a syntactically-safe FTS5 MATCH expression. The primary FTS path -// previously fed the RAW query straight into `content MATCH ${query}` — but FTS5 has its own query -// grammar (column filters like `foo:`, prefix `*`, boolean `AND`/`OR`/`NOT`/`NEAR`, parentheses, quoted -// phrases). Arbitrary input such as `foo:`, an unbalanced `"`, `(`, or `*` raises a SQLite syntax error -// that surfaces as a 500 rather than empty results. We neutralize the grammar by treating the query as -// plain text: tokenize on whitespace and wrap EACH term in a double-quoted FTS5 string literal (internal -// `"` doubled, per FTS5 phrase-escaping), then join with a space. Space-separated quoted phrases are an -// implicit AND in FTS5, so this yields term-AND matching (every term must appear) — a reasonable search -// semantics that treats every character literally and can never throw. A query that tokenizes to nothing -// (empty or whitespace-only) would make MATCH throw on an empty expression, so we emit `""` (a quoted -// empty phrase) which is syntactically valid and matches nothing. -const toFtsMatchQuery = (q: string): string => { - const terms = q.trim().split(/\s+/).filter((t) => t.length > 0) - if (terms.length === 0) return `""` - return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" ") -} - -// Map an im_messages row (snake_case) to the camelCase IMMessage domain model. -const mapMessageRow = (m: { - id: string - group_id: string - sender_id: string - sender_type: string - type: string - content: string - mentions: readonly string[] | null - metadata: unknown - reply_to_id: string | null - created_at: number - updated_at: number - deleted_at: number | null -}): IMMessage => ({ - id: m.id, - groupID: m.group_id, - senderID: m.sender_id, - senderType: m.sender_type, - type: m.type, - content: m.content, - mentions: m.mentions ?? null, - metadata: m.metadata ?? null, - replyToID: m.reply_to_id ?? null, - createdAt: m.created_at, - updatedAt: m.updated_at, - deletedAt: m.deleted_at ?? null, -}) - // Converged to the single canonical definition in `mention-parser.ts` // (V3.8.1 §C.3 / conflict C6) so the new optional metadata fields // (triggers/capabilities/autonomy/context_sources/approval_required/limits) @@ -160,65 +72,6 @@ export interface CreateGroupInput { type: GroupType name: string createdBy: string - // §B3 — optional initial members added alongside the creator (creator is always added as owner). Used - // by the direct-group path to seat the counterparty; when omitted the group starts with just the - // creator (V3.8 behavior, unchanged). - members?: ReadonlyArray<{ memberID: string; memberType: MemberType; role?: MemberRole }> -} - -// §B3 私聊 — create (or return the existing) direct 1:1 group between exactly two participants. The pair -// is canonicalized so the same two participants always map to one group regardless of argument order, -// preventing duplicate direct groups. -export interface CreateDirectGroupInput { - workspaceID: string - projectID?: string - createdBy: string - // The two participants of the direct group. Exactly one must be the creator; the other is the - // counterparty (a user or an agent). Enforced in the repository. - members: ReadonlyArray<{ memberID: string; memberType: MemberType }> - // Optional display name; when omitted a deterministic name is derived from the pair. - name?: string -} - -export interface ListThreadInput { - groupID: string - // The parent message id whose replies (reply_to_id === replyToID) are listed. - replyToID: string - cursor?: string - limit: number -} - -export interface SearchMessagesInput { - workspaceID: string - userID: string - query: string - groupID?: string - senderType?: SenderType - type?: MessageType - // §B3 metadata filter — a `metadata.type` discriminant to match via json_extract (e.g. "code_ref"). - metadataType?: string - cursor?: string - limit: number -} - -export interface CreateAttachmentInput { - workspaceID: string - projectID?: string - groupID?: string - messageID?: string - uploadedBy: string - storagePath: string - filename: string - mime: string - sizeBytes: number - checksum: string -} - -export interface ListAttachmentsInput { - workspaceID: string - groupID?: string - messageID?: string - limit: number } export interface CreateMessageInput { @@ -269,22 +122,12 @@ export interface AddMemberInput { export interface IMRepositoryInterface { readonly listGroups: (input: ListGroupsInput) => Effect.Effect readonly createGroup: (input: CreateGroupInput) => Effect.Effect - // §B3 私聊 — create-or-return the canonical direct group for a participant pair. - readonly createDirectGroup: (input: CreateDirectGroupInput) => Effect.Effect readonly getGroup: (input: GetGroupInput) => Effect.Effect readonly addMember: (input: AddMemberInput) => Effect.Effect readonly listMessages: (input: ListMessagesInput) => Effect.Effect - // §B3 Thread — replies to a parent message, ASC (created_at, id) keyset pagination. - readonly listThread: (input: ListThreadInput) => Effect.Effect - // §B3 搜索 — full-text + metadata search scoped to the caller's group memberships. - readonly searchMessages: (input: SearchMessagesInput) => Effect.Effect readonly createMessage: (input: CreateMessageInput) => Effect.Effect readonly getMessage: (messageID: string) => Effect.Effect readonly markRead: (input: MarkReadInput) => Effect.Effect - // §B3 文件 — attachment records (decoupled from messages). - readonly createAttachment: (input: CreateAttachmentInput) => Effect.Effect - readonly getAttachment: (attachmentID: string) => Effect.Effect - readonly listAttachments: (input: ListAttachmentsInput) => Effect.Effect // Note: listAgents removed - use AgentListProviderService instead } @@ -299,18 +142,6 @@ export const IMRepositoryLive = Layer.effect( Effect.gen(function* () { const { db } = yield* Database.Service - // Feature-detect the FTS5 mirror table. Present ⇒ the FTS5 module was available at migration time and - // triggers keep it synced; absent ⇒ this SQLite build lacks FTS5 and search uses the LIKE fallback. - // Checked per search call (cheap sqlite_master lookup) so a build without FTS5 degrades gracefully. - const ftsTableExists = db - .get<{ name: string }>( - sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'im_messages_fts'`, - ) - .pipe( - Effect.map((row) => row !== undefined && row !== null), - Effect.mapError(dbError("Database operation failed")), - ) - return IMRepository.of({ listGroups: (input) => Effect.gen(function* () { @@ -374,20 +205,6 @@ export const IMRepositoryLive = Layer.effect( joined_at: now, }).pipe(Effect.mapError(dbError("Database operation failed"))) - // Seat any additional initial members (skip a duplicate of the creator). Each insert is - // guarded by the members table's unique index; a caller-supplied duplicate would surface as a - // db error, so we de-dupe the creator here defensively. - for (const m of input.members ?? []) { - if (m.memberID === input.createdBy && m.memberType === "user") continue - yield* db.insert(MemberTable).values({ - group_id: id, - member_id: m.memberID, - member_type: m.memberType, - role: m.role ?? "member", - joined_at: now, - }).pipe(Effect.mapError(dbError("Database operation failed"))) - } - return { id, workspaceID: input.workspaceID, @@ -401,127 +218,6 @@ export const IMRepositoryLive = Layer.effect( } }), - createDirectGroup: (input) => - Effect.gen(function* () { - // §B3 constraint: a direct group has EXACTLY 2 members, either user+user or user+agent. Validate - // the pair before touching the database. - const members = input.members - if (members.length !== 2) { - return yield* new IMRepositoryError({ - message: "A direct group must have exactly 2 members", - }) - } - const [a, b] = members - if (a.memberID === b.memberID && a.memberType === b.memberType) { - return yield* new IMRepositoryError({ - message: "A direct group requires two distinct members", - }) - } - // At least one member must be a user (user+user or user+agent — never agent+agent). - if (a.memberType !== "user" && b.memberType !== "user") { - return yield* new IMRepositoryError({ - message: "A direct group must include at least one user member", - }) - } - // The creator must be one of the two participants (a user cannot open a private chat between - // two other parties on their behalf). - const creatorIsParticipant = members.some( - (m) => m.memberID === input.createdBy && m.memberType === "user", - ) - if (!creatorIsParticipant) { - return yield* new IMRepositoryError({ - message: "The creator must be one of the direct group participants", - }) - } - - // Uniqueness guard: canonicalize the pair to a deterministic key and look for an existing, - // non-deleted direct group between the same two participants in this workspace. Reuse it if - // present (idempotent open-chat semantics) rather than creating a duplicate. - const canonical = members - .map((m) => `${m.memberType}:${m.memberID}`) - .sort() - .join("|") - - const existing = yield* db - .select({ group: GroupTable }) - .from(GroupTable) - .where( - and( - eq(GroupTable.workspace_id, input.workspaceID), - eq(GroupTable.type, "direct"), - isNull(GroupTable.deleted_at), - ), - ) - .all() - .pipe(Effect.mapError(dbError("Database operation failed"))) - - for (const { group: g } of existing) { - const rows = yield* db - .select({ member_id: MemberTable.member_id, member_type: MemberTable.member_type }) - .from(MemberTable) - .where(eq(MemberTable.group_id, g.id)) - .all() - .pipe(Effect.mapError(dbError("Database operation failed"))) - const key = rows - .map((r) => `${r.member_type}:${r.member_id}`) - .sort() - .join("|") - if (key === canonical) { - return { - id: g.id, - workspaceID: g.workspace_id, - projectID: g.project_id ?? null, - type: g.type, - name: g.name, - createdBy: g.created_by, - createdAt: g.created_at, - updatedAt: g.updated_at, - deletedAt: g.deleted_at ?? null, - } - } - } - - // No existing pair — create it. The creator is seated as owner; the counterparty as member. - const id = IMID.GroupID.create() - const now = Date.now() - const name = input.name ?? `direct:${canonical}` - - yield* db.insert(GroupTable).values({ - id, - workspace_id: input.workspaceID, - project_id: input.projectID ?? null, - type: "direct", - name, - created_by: input.createdBy, - created_at: now, - updated_at: now, - deleted_at: null, - }).pipe(Effect.mapError(dbError("Database operation failed"))) - - for (const m of members) { - const isCreator = m.memberID === input.createdBy && m.memberType === "user" - yield* db.insert(MemberTable).values({ - group_id: id, - member_id: m.memberID, - member_type: m.memberType, - role: isCreator ? "owner" : m.memberType === "agent" ? "agent" : "member", - joined_at: now, - }).pipe(Effect.mapError(dbError("Database operation failed"))) - } - - return { - id, - workspaceID: input.workspaceID, - projectID: input.projectID ?? null, - type: "direct", - name, - createdBy: input.createdBy, - createdAt: now, - updatedAt: now, - deletedAt: null, - } - }), - getGroup: (input) => Effect.gen(function* () { const group = yield* db @@ -633,123 +329,6 @@ export const IMRepositoryLive = Layer.effect( } }), - listThread: (input) => - Effect.gen(function* () { - const limit = input.limit + 1 - const cursor = parseCompositeCursor(input.cursor) - - // Thread = messages whose reply_to_id points at the parent, scoped to the group and excluding - // soft-deleted rows. ORDER BY (created_at ASC, id ASC) gives a stable chronological thread; the - // composite keyset advances past the last (created_at, id) seen. Uses idx_im_messages_thread - // (group_id, reply_to_id, created_at). - const whereClause = and( - eq(MessageTable.group_id, input.groupID as IMID.GroupID), - eq(MessageTable.reply_to_id, input.replyToID as IMID.MessageID), - isNull(MessageTable.deleted_at), - cursor !== undefined - ? or( - gt(MessageTable.created_at, cursor.createdAt), - and(eq(MessageTable.created_at, cursor.createdAt), gt(MessageTable.id, cursor.id as IMID.MessageID)), - ) - : undefined, - ) - - const messages = yield* db - .select() - .from(MessageTable) - .where(whereClause) - .orderBy(asc(MessageTable.created_at), asc(MessageTable.id)) - .limit(limit) - .all() - .pipe(Effect.mapError(dbError("Database operation failed"))) - - const hasMore = messages.length > input.limit - const resultMessages = hasMore ? messages.slice(0, input.limit) : messages - const last = resultMessages[resultMessages.length - 1] - - return { - messages: resultMessages.map(mapMessageRow), - nextCursor: hasMore && last ? encodeCompositeCursor(last.created_at, last.id) : null, - hasMore, - } - }), - - searchMessages: (input) => - Effect.gen(function* () { - const limit = input.limit + 1 - const cursor = parseCompositeCursor(input.cursor) - - // Permission scoping (the main risk): a user may only search groups they belong to. The - // membership join (member_id = userID, member_type = "user") is what enforces this — a message - // in a group the caller isn't a member of is never joined, so it can never surface. This holds - // for BOTH the FTS and the LIKE-fallback path. - const membershipJoin = and( - eq(MemberTable.group_id, MessageTable.group_id), - eq(MemberTable.member_id, input.userID), - eq(MemberTable.member_type, "user"), - ) - - // Column / metadata filters. Workspace scoping is via the GroupTable join (im_messages has no - // workspace_id column — it lives on the group). - const filters = [ - eq(GroupTable.workspace_id, input.workspaceID), - isNull(MessageTable.deleted_at), - isNull(GroupTable.deleted_at), - input.groupID ? eq(MessageTable.group_id, input.groupID as IMID.GroupID) : undefined, - input.senderType ? eq(MessageTable.sender_type, input.senderType) : undefined, - input.type ? eq(MessageTable.type, input.type) : undefined, - input.metadataType - ? sql`json_extract(${MessageTable.metadata}, '$.type') = ${input.metadataType}` - : undefined, - cursor !== undefined - ? or( - gt(MessageTable.created_at, cursor.createdAt), - and(eq(MessageTable.created_at, cursor.createdAt), gt(MessageTable.id, cursor.id as IMID.MessageID)), - ) - : undefined, - ] - - // Feature-detect FTS5: the fts table only exists when the module was available at migration - // time. When absent (or empty for other reasons) fall back to a LIKE scan on content. - const ftsAvailable = yield* ftsTableExists - - const rows = ftsAvailable - ? yield* db - .select({ message: MessageTable }) - .from(MessageTable) - .innerJoin(MemberTable, membershipJoin) - .innerJoin(GroupTable, eq(GroupTable.id, MessageTable.group_id)) - .innerJoin( - sql`im_messages_fts`, - sql`im_messages_fts.msg_id = ${MessageTable.id} AND im_messages_fts.content MATCH ${toFtsMatchQuery(input.query)}`, - ) - .where(and(...filters)) - .orderBy(asc(MessageTable.created_at), asc(MessageTable.id)) - .limit(limit) - .all() - .pipe(Effect.mapError(dbError("Database operation failed"))) - : yield* db - .select({ message: MessageTable }) - .from(MessageTable) - .innerJoin(MemberTable, membershipJoin) - .innerJoin(GroupTable, eq(GroupTable.id, MessageTable.group_id)) - .where(and(like(MessageTable.content, `%${escapeLike(input.query)}%`), ...filters)) - .orderBy(asc(MessageTable.created_at), asc(MessageTable.id)) - .limit(limit) - .all() - .pipe(Effect.mapError(dbError("Database operation failed"))) - - const hasMore = rows.length > input.limit - const resultRows = hasMore ? rows.slice(0, input.limit) : rows - const last = resultRows[resultRows.length - 1]?.message - - return { - messages: resultRows.map((r) => mapMessageRow(r.message)), - nextCursor: hasMore && last ? encodeCompositeCursor(last.created_at, last.id) : null, - hasMore, - } - }), - createMessage: (input) => Effect.gen(function* () { const id = IMID.MessageID.create() @@ -829,114 +408,6 @@ export const IMRepositoryLive = Layer.effect( ) .pipe(Effect.mapError(dbError("Database operation failed"))) }), - - createAttachment: (input) => - Effect.gen(function* () { - const id = IMID.AttachmentID.create() - const now = Date.now() - - yield* db.insert(AttachmentTable).values({ - id, - workspace_id: input.workspaceID, - project_id: input.projectID ?? null, - group_id: (input.groupID as IMID.GroupID | undefined) ?? null, - message_id: (input.messageID as IMID.MessageID | undefined) ?? null, - uploaded_by: input.uploadedBy, - storage_path: input.storagePath, - filename: input.filename, - mime: input.mime, - size_bytes: input.sizeBytes, - checksum: input.checksum, - created_at: now, - deleted_at: null, - }).pipe(Effect.mapError(dbError("Database operation failed"))) - - return { - id, - workspaceID: input.workspaceID, - projectID: input.projectID ?? null, - groupID: input.groupID ?? null, - messageID: input.messageID ?? null, - uploadedBy: input.uploadedBy, - storagePath: input.storagePath, - filename: input.filename, - mime: input.mime, - sizeBytes: input.sizeBytes, - checksum: input.checksum, - createdAt: now, - deletedAt: null, - } - }), - - getAttachment: (attachmentID) => - Effect.gen(function* () { - const row = yield* db - .select() - .from(AttachmentTable) - .where( - and( - eq(AttachmentTable.id, attachmentID as IMID.AttachmentID), - isNull(AttachmentTable.deleted_at), - ), - ) - .get() - .pipe(Effect.mapError(dbError("Database operation failed"))) - - if (!row) return undefined - return mapAttachmentRow(row) - }), - - listAttachments: (input) => - Effect.gen(function* () { - const rows = yield* db - .select() - .from(AttachmentTable) - .where( - and( - eq(AttachmentTable.workspace_id, input.workspaceID), - isNull(AttachmentTable.deleted_at), - input.groupID ? eq(AttachmentTable.group_id, input.groupID as IMID.GroupID) : undefined, - input.messageID ? eq(AttachmentTable.message_id, input.messageID as IMID.MessageID) : undefined, - ), - ) - .orderBy(desc(AttachmentTable.created_at)) - .limit(input.limit) - .all() - .pipe(Effect.mapError(dbError("Database operation failed"))) - - return rows.map(mapAttachmentRow) - }), }) }), ) - -// Map an im_attachments row (snake_case) to the camelCase IMAttachment domain model. -const mapAttachmentRow = (a: { - id: string - workspace_id: string - project_id: string | null - group_id: string | null - message_id: string | null - uploaded_by: string - storage_path: string - filename: string - mime: string - size_bytes: number - checksum: string - created_at: number - deleted_at: number | null -}): IMAttachment => ({ - id: a.id, - workspaceID: a.workspace_id, - projectID: a.project_id ?? null, - groupID: a.group_id ?? null, - messageID: a.message_id ?? null, - uploadedBy: a.uploaded_by, - storagePath: a.storage_path, - filename: a.filename, - mime: a.mime, - sizeBytes: a.size_bytes, - checksum: a.checksum, - createdAt: a.created_at, - deletedAt: a.deleted_at ?? null, -}) diff --git a/packages/core/src/im/sql.ts b/packages/core/src/im/sql.ts index 8885f4fa..ba74ac9e 100644 --- a/packages/core/src/im/sql.ts +++ b/packages/core/src/im/sql.ts @@ -4,8 +4,8 @@ import { Timestamps } from "../database/schema.sql" import { ProjectTable } from "../project/sql" import * as IMID from "./id" -// V3.8: project / system · V4.0 §B3: direct (private 1:1 — user+user or user+agent, exactly 2 members) -export const GroupType = Schema.Literals(["project", "system", "direct"]) +// V3.8: project / system +export const GroupType = Schema.Literals(["project", "system"]) export type GroupType = Schema.Schema.Type // V3.8: owner / member / agent @@ -140,13 +140,6 @@ export const MessageTable = sqliteTable( mentions: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type(), reply_to_id: text().$type(), - // V4.0 §B4 — the DeepAgent Event Bus event this message was produced from (agent replies / proactive - // pushes carry it; user messages that publish im.message.created link back via it). NULL for legacy - // V3.8 messages — nullable so the V3.8 write path is unchanged (§H compatibility). - event_id: text(), - // V4.0 §B4 — delivery lifecycle for event-driven messages: pending | delivered | failed. NULL ⇒ - // the legacy synchronous path (no delivery tracking). Nullable for V3.8 compatibility. - delivery_status: text().$type<"pending" | "delivered" | "failed">(), created_at: integer().notNull().$default(() => Date.now()), updated_at: integer().notNull().$onUpdate(() => Date.now()), deleted_at: integer(), @@ -156,44 +149,5 @@ export const MessageTable = sqliteTable( // migration. Drizzle's sqlite-core types in this repo version do not expose // `.where()` on indexes, so the partial predicate lives in the migration. index("idx_im_messages_active").on(table.group_id, table.created_at, table.id), - // V4.0 §B4 — thread pagination (reply_to_id chains) + event linkage lookups. - index("idx_im_messages_thread").on(table.group_id, table.reply_to_id, table.created_at), - index("idx_im_messages_event").on(table.event_id), - ], -) - -// V4.0 §B3/§B4 schema: im_attachments -// -// A file record is DECOUPLED from a message ("文件记录与消息解耦"): `message_id` is nullable so a file -// can be uploaded first (returning its id/checksum) and only later referenced by a message — or never -// referenced at all. `storage_path` is a SERVER-DERIVED absolute path on local disk (workspace data -// dir), never the client filename, which eliminates path-traversal. `checksum` is the sha256 of the -// bytes (integrity + dedup signal). Soft delete via `deleted_at` mirrors the rest of the IM schema. -export const AttachmentTable = sqliteTable( - "im_attachments", - { - id: text().$type().primaryKey(), - // Grouping key (routed workspace id or working directory) — same semantics as im_groups.workspace_id. - workspace_id: text().notNull(), - project_id: text().references(() => ProjectTable.id, { onDelete: "cascade" }), - // Nullable: an attachment MAY be scoped to a group / bound to a message, or exist standalone. - group_id: text().$type(), - message_id: text().$type(), - uploaded_by: text().notNull(), - // Absolute on-disk path, server-derived from ids (never the client filename). - storage_path: text().notNull(), - // Original client filename — retained for display/download only, never used to build a path. - filename: text().notNull(), - mime: text().notNull(), - size_bytes: integer().notNull(), - // sha256 hex digest of the stored bytes. - checksum: text().notNull(), - created_at: integer().notNull().$default(() => Date.now()), - deleted_at: integer(), - }, - (table) => [ - index("idx_im_attachments_workspace").on(table.workspace_id, table.created_at), - index("idx_im_attachments_message").on(table.message_id), - index("idx_im_attachments_group").on(table.group_id), ], ) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index e0ba7a00..34418c3f 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({ skill: new SkillV2.Info({ name: "customize-deepagent-code", description: - "Use ONLY when the user is editing or creating deepagent-code's own configuration: config.jsonc, config.json, files under .deepagent-code/, or files under ~/.deepagent/code/. Also use when creating or fixing deepagent-code agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring deepagent-code itself.", + "Use ONLY when the user is editing or creating deepagent-code's own configuration: deepagent-code.json, deepagent-code.jsonc, files under .deepagent-code/, or files under ~/.config/deepagent-code/. Also use when creating or fixing deepagent-code agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring deepagent-code itself.", location: AbsolutePath.make("/builtin/customize-deepagent-code.md"), content: CustomizeOpencodeContent, }), diff --git a/packages/core/src/plugin/skill/customize-deepagent-code.md b/packages/core/src/plugin/skill/customize-deepagent-code.md index 313f1f33..f081f7e6 100644 --- a/packages/core/src/plugin/skill/customize-deepagent-code.md +++ b/packages/core/src/plugin/skill/customize-deepagent-code.md @@ -40,11 +40,11 @@ already-loaded config until then. | Scope | Path | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Project config | `./deepagent-code.json`, `./deepagent-code.jsonc`, or `.deepagent-code/deepagent-code.json` (deepagent-code walks up from the cwd to the worktree root) | -| Global config | `~/.deepagent/code/config.jsonc` (or `config.json`) | +| Global config | `~/.config/deepagent-code/deepagent-code.json` (NOT `~/.deepagent-code/`) | | Project agents | `.deepagent-code/agent/.md` or `.deepagent-code/agents/.md` | -| Global agents | `~/.deepagent/code/agent(s)/.md` | +| Global agents | `~/.config/deepagent-code/agent(s)/.md` | | Project skills | `.deepagent-code/skill(s)//SKILL.md` | -| Global skills | `~/.deepagent/code/skill(s)//SKILL.md` | +| Global skills | `~/.config/deepagent-code/skill(s)//SKILL.md` | | External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | Configs from each scope are deep-merged. Project overrides global. Unknown diff --git a/packages/core/src/provider-official.ts b/packages/core/src/provider-official.ts index 61027b75..d615e37f 100644 --- a/packages/core/src/provider-official.ts +++ b/packages/core/src/provider-official.ts @@ -23,6 +23,12 @@ * - `zai-coding-plan` api.z.ai /api/coding/paas/v4 (subscription, intl) * Each takes its own API key from the auth key store (users connect only the ones * they hold); coding-plan thinking/billing keys off the catalog `api.url`, not config. + * + * The Kimi/Moonshot family is two faces (brand × billing plane) — note they use + * DIFFERENT protocols, both resolved from the models.dev catalog: + * - `kimi-for-coding` api.kimi.com /coding/v1 (subscription, `@ai-sdk/anthropic` + * — SDK appends `/messages`; the `/v1` suffix is mandatory) + * - `moonshotai-cn` api.moonshot.cn /v1 (pay-as-you-go, `@ai-sdk/openai-compatible`) */ export const OFFICIAL_PROVIDER_IDS = [ "openai", @@ -32,6 +38,8 @@ export const OFFICIAL_PROVIDER_IDS = [ "zhipuai-coding-plan", "zai", "zai-coding-plan", + "kimi-for-coding", + "moonshotai-cn", "xai", "google", ] as const diff --git a/packages/core/src/public/deepagent-code.ts b/packages/core/src/public/deepagent-code.ts new file mode 100644 index 00000000..2c63c418 --- /dev/null +++ b/packages/core/src/public/deepagent-code.ts @@ -0,0 +1,130 @@ +export * as DeepAgentCode from "./deepagent-code" + +import { Context, Effect, Layer } from "effect" +import { Catalog } from "../catalog" +import { Database } from "../database/database" +import { EventV2 } from "../event" +import { LocationServiceMap } from "../location-layer" +import { PluginBoot } from "../plugin/boot" +import { ProjectV2 } from "../project" +import { SessionV2 } from "../session" +import * as SessionExecutionLocal from "../session/execution/local" +import { SessionProjector } from "../session/projector" +import { SessionStore } from "../session/store" +import { ApplicationTools } from "../tool/application-tools" +import { Session } from "./session" +import { Tool } from "./tool" + +export interface Interface { + readonly sessions: Session.Interface + readonly tools: Tool.Interface +} + +/** Intentional public native API for Effect applications embedding DeepAgent Code. */ +export class Service extends Context.Service()("@deepagent-code/public/DeepAgentCode") {} + +class SessionModelValidation extends Context.Service< + SessionModelValidation, + { + readonly validate: ( + input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, + ) => Effect.Effect + } +>()("@deepagent-code/public/DeepAgentCode/SessionModelValidation") {} + +const LocationServicesLayer = LocationServiceMap.layer +const SessionModelValidationLayer = Layer.effect( + SessionModelValidation, + Effect.gen(function* () { + const locations = yield* LocationServiceMap + return SessionModelValidation.of({ + validate: Effect.fn("DeepAgentCode.sessions.validateModel")(function* (input) { + yield* Effect.gen(function* () { + yield* (yield* PluginBoot.Service).wait() + const catalog = yield* Catalog.Service + const model = (yield* catalog.model.available()).find( + (model) => model.providerID === input.model.providerID && model.id === input.model.id, + ) + if (!model) + return yield* new Session.ModelUnavailableError({ + providerID: input.model.providerID, + modelID: input.model.id, + }) + if ( + input.model.variant !== undefined && + input.model.variant !== "default" && + !model.variants.some((variant) => variant.id === input.model.variant) + ) + return yield* new Session.VariantUnavailableError({ + providerID: input.model.providerID, + modelID: input.model.id, + variant: input.model.variant, + }) + }).pipe(Effect.provide(locations.get(input.location))) + }), + }) + }), +) + +const SessionsLayer = Layer.merge( + SessionV2.layer.pipe( + Layer.provide(SessionProjector.layer), + Layer.provide(SessionExecutionLocal.layer), + Layer.provide(SessionStore.layer), + Layer.provide(EventV2.layer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), + Layer.orDie, + ), + SessionModelValidationLayer, +).pipe(Layer.provide(LocationServicesLayer)) +const ApplicationToolsLayer = ApplicationTools.layer + +// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const tools = yield* ApplicationTools.Service + const validation = yield* SessionModelValidation + return Service.of({ + tools: { register: tools.register }, + sessions: { + create: (input) => + sessions.create({ + id: input.id, + agent: input.agent, + model: input.model, + location: input.location, + }), + get: sessions.get, + list: sessions.list, + switchModel: Effect.fn("DeepAgentCode.sessions.switchModel")(function* (input) { + const session = yield* sessions.get(input.sessionID) + yield* validation.validate({ ...input, location: session.location }) + yield* sessions.switchModel(input) + }), + interrupt: sessions.interrupt, + prompt: (input) => + sessions.prompt({ + id: input.id, + sessionID: input.sessionID, + prompt: input.prompt, + delivery: input.delivery, + }), + messages: (input) => + sessions.messages({ + sessionID: input.sessionID, + limit: input.limit, + order: input.order, + cursor: input.cursor, + }), + message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }), + context: sessions.context, + events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }), + }, + }) + }), +).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer))) + +// TODO: Add DeepAgent Code.create(...) as the Promise facade over the same native API semantics. diff --git a/packages/core/src/public/index.ts b/packages/core/src/public/index.ts index 2b3b8460..b3b2c80f 100644 --- a/packages/core/src/public/index.ts +++ b/packages/core/src/public/index.ts @@ -1,6 +1,7 @@ /** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */ export { Agent } from "./agent" export { Model } from "./model" +export { DeepAgentCode } from "./deepagent-code" export { Session } from "./session" export { Tool } from "./tool" export { Location } from "./location" diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index f90334f6..f99848b4 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -50,36 +50,6 @@ Rules: - Preserve exact file paths, commands, error strings, and identifiers when known. - Do not mention the summary process or that context was compacted.` -// V4.0.1 P1 (§3.4) — the NARROWED, four-bucket summary template. Responsibility separation: the summary -// records ONLY 思路 + 待办 (progress+decisions / constraints+prefs / next steps / data references) and -// explicitly does NOT record file contents / env values / diagnostics snapshots — those volatile facts -// are re-injected at their LATEST value by the World State layer (never captured here, where they would -// go stale). "Data References" keeps only the reference (path/identifier/link), never the content. -// Gated by `worldStateReinjection` at the deepagent-code call site: with the flag OFF, buildPrompt uses -// the legacy SUMMARY_TEMPLATE and NOTHING is re-injected (no information hole). -const NARROW_SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside