diff --git a/AGENTS.md b/AGENTS.md index 5d493688..0a7e42e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,56 +1,250 @@ # AGENTS.md -HAPI is a local-first platform for running coding agents with remote control via web/phone. -CLI wraps agents → hub (Socket.IO) → web/native clients (REST + SSE). +Work style: telegraph; noun-phrases ok; drop grammar; -## Task boundaries +Short guide for AI agents in this repo. Prefer progressive loading: start with the root README, then package READMEs as needed. -- Complete the requested deliverable and relevant verification; do not stop at the first implementation unless the user requested a review checkpoint. -- Fix causes within the task's scope. Report unrelated problems rather than turning them into refactors or additional features. -- Make reasonable, reversible choices and continue. Ask when missing information materially affects correctness, an action needs additional authorization, or progress requires overwriting someone else's changes. Continue unaffected work. -- Preserve existing user/agent changes. Editing or generating files does not imply permission to commit, push, or release. -- Keep communication concise and clear; report results, checks performed, and remaining limitations. +## What is HAPI? -## Find context when needed +Local-first platform for running AI coding agents (Claude Code, Codex, Gemini) with remote control via web/phone. CLI wraps agents and connects to hub; hub serves web app and handles real-time sync. -Start with the task's files; read only relevant sections of these references, not a fixed sequence of READMEs. +## Repo layout -| Task area | Entry points | -|-----------|--------------| -| Product, setup, supported agents | [README.md](README.md), [agent guide](docs/guide/agents.md) | -| Agent wrappers, CLI commands, runner | [cli/README.md](cli/README.md); for bootstrap/handoff changes, [session lifecycle invariants](cli/README.md#session-lifecycle-invariants) | -| Hub APIs, auth, sync, notifications | [hub/README.md](hub/README.md) | -| Web routes, components, data fetching | [web/README.md](web/README.md); for optional feature discovery, [FUE](web/README.md#first-user-experience-fue) | -| Shared wire types and validation | `shared/src/types.ts`, `schemas.ts`, `socket.ts`, `modes.ts` | -| Native API contract, chat conformance | [client contract](docs/api/client-contract/index.md), [iOS](ios/README.md), [Android](android/README.md) | -| Encrypted native push relay | [relay/README.md](relay/README.md) | -| User docs / marketing site | `docs/` (VitePress) / `website/` | +``` +cli/ - CLI binary, agent wrappers, runner daemon +hub/ - HTTP API + Socket.IO + SSE + Telegram bot +web/ - React PWA for remote control +ios/ - Native SwiftUI app (in development) +android/ - Native Kotlin Compose app (in development) +shared/ - Common types, schemas, utilities +shared/fixtures/ - Golden chat fixtures, generated from web pipeline (never hand-edit) +docs/ - VitePress documentation site +website/ - Marketing site +``` -## Repository conventions +Bun workspaces; `shared` consumed by cli, hub, web. `ios`/`android` outside workspaces (Xcode / Gradle toolchains). -- Bun workspaces: `cli`, `shared`, `hub`, `web`, `website`, `docs`, `relay`. Run workspace scripts from the root; package-scoped commands may use `bun run --cwd ...` or that package's directory. iOS and Android use separate toolchains. -- TypeScript strict; keep code typed. Prefer 4-space indentation. `@/*` resolves to a package's `src/*`. -- Shared protocol is `@hapi/protocol`; runtime schemas live in `shared/src/schemas.ts` (Zod). -- No backward compatibility required for formats changed by the task; do not add compatibility layers or change unrelated formats. +## Architecture overview -## Cross-component invariants +``` +┌─────────┐ Socket.IO ┌─────────┐ SSE/REST ┌─────────┐ +│ CLI │ ──────────── │ Hub │ ──────────── │ Web │ +│ (agent) │ │ (server)│ │ (PWA) │ +└─────────┘ └─────────┘ └─────────┘ + │ │ │ + ├─ Wraps Claude/Codex ├─ SQLite persistence ├─ TanStack Query + ├─ Socket.IO client ├─ Session cache ├─ SSE for updates + └─ RPC handlers ├─ RPC gateway └─ assistant-ui + └─ Telegram bot +``` -- CLI↔hub uses Socket.IO `/cli` with the CLI access token. Web terminals use `/terminal` with a client JWT; ordinary web/native updates use REST + SSE. Preserve namespace isolation (`CLI_API_TOKEN:`). -- Metadata/state updates are versioned; preserve stale-update rejection. Permission controls use per-flavor catalogs in `shared/src/modes.ts`, further constrained by session capabilities. -- `shared/fixtures/**` is generated from the web chat pipeline, the source of truth for native conformance. Never hand-edit fixtures. For changes to fixture inputs or generation (paths in [.github/workflows/fixtures.yml](.github/workflows/fixtures.yml)), run `bun run gen:fixtures` and include any generated changes in the deliverable. CI checks drift and runs native conformance on fixture changes. +**Data flow:** +1. CLI spawns agent (claude/codex/gemini), connects to hub via Socket.IO +2. Agent events → CLI → hub (socket `message` event) → DB + SSE broadcast +3. Web subscribes to SSE `/api/events`, receives live updates +4. User actions → Web → hub REST API → RPC to CLI → agent -## Verification and completion +## Reference docs -Choose checks by the change's impact, not by the number of workflow steps: +- `README.md` - User overview, quick start +- `cli/README.md` - CLI commands, config, runner +- `hub/README.md` - Hub config, HTTP API, Socket.IO events +- `web/README.md` - Routes, components, hooks +- `docs/guide/` - User guides (installation, how-it-works, FAQ) -| Change | Verification | -|--------|--------------| -| Documentation only | Check edited content, local links, and diff; no code test suite. | -| Package-local code | Relevant tests and the package's typecheck where available; add regression coverage when needed. | -| Shared contracts, dependencies, broad cross-package behavior | `bun typecheck && bun run test`, plus affected integration/conformance checks. | -| Native code | Relevant checks from the iOS/Android README using available toolchains. | +## Shared rules -- Root scripts: `bun run test:` for `cli`, `hub`, `web`, `shared`, `relay`; `bun run typecheck:` for `cli`, `hub`, `web`, `relay`. Shared types are checked through consumers. CLI/web tests use Vitest; hub/shared/relay use Bun test. Use file filters for focused runs. -- Within existing permissions, run and retry relevant local checks without asking at each step. Fix failures caused by the task; report unrelated failures. If tools or permissions are unavailable, complete other work and state what remains unverified; do not bootstrap native toolchains or wait on CI unless the task requires it. -- Reuse passing checks when code, dependencies, and environment are unchanged; commit/push/PR transitions alone do not require reruns. Run repository-wide checks when explicitly requested as well. -- Review this task's changes for correctness, security, and regressions. For local work, inspect unstaged/staged diffs (`git diff`, `git diff --cached`) and new files; for a branch/PR review, use the actual target branch's merge-base diff. Local self-review does not require a GitHub event, remote review, or posting comments. +- No backward compatibility: breaking old formats freely +- Prioritize Pragmatism, and Avoid Overengineering. +- Write necessary tests ONLY. +- TypeScript strict; no untyped code +- Bun workspaces; run `bun` commands from repo root +- Path alias `@/*` maps to `./src/*` per package +- Prefer 4-space indentation +- Zod for runtime validation (schemas in `shared/src/schemas.ts`) + +## Common commands (repo root) + +```bash +bun typecheck # All packages +bun run test # cli + hub + web + shared tests +bun run dev # hub + web concurrently +bun run build:single-exe # All-in-one binary +bun run gen:fixtures # Regenerate shared/fixtures/ from web pipeline +cd android && ./gradlew :core:protocol:test # Android protocol conformance +``` + +## Local binary deployment + +The macOS all-in-one executable is ad-hoc signed. **Do not overwrite +`~/.hapi/bin/hapi` in place, even with an atomic rename.** macOS caches the +Mach-O code signature by executable path/mtime; replacing that path can make +the embedded signature disagree with the cached signature and kill every new +process with `OS_REASON_CODESIGNING` / `embedded signature doesn't match +attached signature` (often exit 137). A plain `cp` also loses the signed +mtime. + +Safe deployment sequence: + +1. Build the executable. +2. Remove Bun's linker signature, then apply a fresh ad-hoc signature: + + ```bash + codesign --remove-signature cli/dist-exe/bun-darwin-arm64/hapi 2>/dev/null || true + codesign --force --sign - cli/dist-exe/bun-darwin-arm64/hapi + codesign --verify --deep --strict cli/dist-exe/bun-darwin-arm64/hapi + ``` + +3. Copy with `cp -p` to a **new, versioned filename** under `~/.hapi/bin/`; + never reuse a previous executable pathname. Verify and run `--help` from + that versioned path. +4. Point `~/.hapi/bin/hapi` at the versioned file with a symlink. Keep the + previous versioned file for rollback; do not delete it while sessions are + running. +5. Restart the launch agent, then check `curl -fsS + http://127.0.0.1:3006/health`, `~/.hapi/bin/hapi --version`, and `hapi + runner status`. If health fails, restore the previous symlink before doing + anything else. + +`docs/local-deployment.md` contains the same rationale and rollback checklist. + +iOS tests run in CI (`ios.yml`: macOS `swift test`); no local Xcode/Swift toolchain assumed. + +## Key source dirs + +### CLI (`cli/src/`) +- `api/` - Hub connection (Socket.IO client, auth) +- `claude/` - Claude Code integration (wrapper, hooks) +- `codex/` - Codex mode integration +- `agent/` - Multi-agent support (Gemini via ACP) +- `runner/` - Background daemon for remote spawn +- `commands/` - CLI subcommands (auth, runner, doctor) +- `modules/` - Tool implementations (ripgrep, difftastic, git) +- `ui/` - Terminal UI (Ink components) + +### Hub (`hub/src/`) +- `web/routes/` - REST API endpoints +- `socket/` - Socket.IO setup +- `socket/handlers/cli/` - CLI event handlers (session, terminal, machine, RPC) +- `sync/` - Core logic (sessionCache, messageService, rpcGateway) +- `store/` - SQLite persistence (better-sqlite3) +- `sse/` - Server-Sent Events manager +- `telegram/` - Bot commands, callbacks +- `notifications/` - Push (VAPID) and Telegram notifications +- `config/` - Settings loading, token generation +- `visibility/` - Client visibility tracking + +### Web (`web/src/`) +- `routes/` - TanStack Router pages +- `routes/sessions/` - Session views (chat, files, terminal) +- `components/` - Reusable UI (SessionList, SessionChat, NewSession/) +- `hooks/queries/` - TanStack Query hooks +- `hooks/mutations/` - Mutation hooks +- `hooks/useSSE.ts` - SSE subscription +- `api/client.ts` - API client wrapper + +### Shared (`shared/src/`) +- `types.ts` - Core types (Session, Message, Machine) +- `schemas.ts` - Zod schemas for validation +- `socket.ts` - Socket.IO event types +- `messages.ts` - Message parsing utilities +- `modes.ts` - Permission/model mode definitions + +### iOS (`ios/`) +- `Packages/HapiKit/` - local SPM package: `HapiProtocol` (wire models + chat pipeline, fixtures-verified), `HapiClient` (API/auth/SSE/stores) +- `Hapi/` + `Hapi.xcodeproj` - thin SwiftUI app target + +### Android (`android/`) +- `:core:protocol` - pure JVM wire types + chat pipeline (fixtures-verified) +- `:core:data` - transport (OkHttp/SSE), auth, stores +- `:app` - Compose UI, navigation, deep links, FCM + +## Protocol conformance (native apps) + +- `shared/fixtures/**` machine-generated from the web chat pipeline (source of truth). NEVER hand-edit; edit `web/scripts/fixtures/cases/` + regenerate. +- Changing `web/src/chat/**`, `web/src/lib/message-window-store.ts`, or `web/src/lib/sessionPatch.ts`: run `bun run gen:fixtures`, commit the diff. CI enforces (`.github/workflows/fixtures.yml`); fixture diffs auto-trigger iOS/Android conformance suites (`ios.yml`/`android.yml`). +- Native client contract docs: `docs/api/client-contract/` (auth, rest, sse, pagination, messages, errors). +- Tracks: `ios/` (SwiftUI, iOS 17+) + `android/` (Kotlin Compose, minSdk 26) — independent codebases, share only contract + fixtures. Plan: `~/.claude/plans/web-pwa-abundant-yeti.md`. + +## Pre-push self-review (agents) + +Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pre-push-review/`). + +1. **Mechanical:** `bun typecheck && bun run test` (matches `.github/workflows/test.yml`) +2. **Logic:** skim `git diff origin/main...HEAD`; apply `.github/prompts/codex-pr-review.md` as a local Major checklist (no Codex required) +3. **Style:** optional + +## Testing + +- Test framework: Vitest (via `bun run test`) +- Test files: `*.test.ts` next to source +- Run: `bun run test` (from root) or `bun run test` (from package) +- Hub tests: `hub/src/**/*.test.ts` +- CLI tests: `cli/src/**/*.test.ts` +- Web tests: `web/src/**/*.test.{ts,tsx}` (fixtures self-check: `web/src/chat/fixtures.test.ts`) + +## Common tasks + +| Task | Key files | +|------|-----------| +| Add CLI command | `cli/src/commands/`, `cli/src/index.ts` | +| Add API endpoint | `hub/src/web/routes/`, register in `hub/src/web/index.ts` | +| Add Socket.IO event | `hub/src/socket/handlers/cli/`, `shared/src/socket.ts` | +| Add web route | `web/src/routes/`, `web/src/router.tsx` | +| Add web component | `web/src/components/` | +| Modify session logic | `hub/src/sync/sessionCache.ts`, `hub/src/sync/syncEngine.ts` | +| Modify message handling | `hub/src/sync/messageService.ts` | +| Add notification type | `hub/src/notifications/` | +| Add shared type | `shared/src/types.ts`, `shared/src/schemas.ts` | + +## Important patterns + +- **RPC**: CLI registers handlers (`rpc-register`), hub routes requests via `rpcGateway.ts` +- **Versioned updates**: CLI sends `update-metadata`/`update-state` with version; hub rejects stale +- **Session modes**: `local` (terminal) vs `remote` (web-controlled); switchable mid-session +- **Permission modes**: `default`, `acceptEdits`, `auto`, `bypassPermissions`, `plan` +- **Namespaces**: Multi-user isolation via `CLI_API_TOKEN:` suffix + +## Adding new web features — consider an FUE + +When you ship a non-essential feature (the 20% of sessions, not the 80%), consider wrapping its affordance in the generic First-User-Experience primitive so existing users discover it without a giant always-visible UI block. + +- **Hook**: `web/src/lib/use-fue.ts` — `useFue(featureId)` returns `{ status, engage, dismiss }`. Storage namespace `hapi.fue.v1.` (one localStorage key per feature, isolated from any upstream onboarding flow). +- **Components**: `web/src/components/Fue.tsx` — `` (small pulsing badge for the affordance) and `` (portal-rendered popover with title/body + "Got it" affirmative-action dismiss). + +Pattern (~10 lines around the affordance): + +```tsx +const fue = useFue('my-feature') +const buttonRef = useRef(null) +return ( + <> + + {fue.status === 'engaging' ? ( + + ) : null} + +) +``` + +Rules: +- Affirmative action only: there is no auto-timeout — user dismisses by clicking "Got it" (reading speed varies). +- The FUE dot and any feature-specific badge (e.g. an entry counter) should be **mutually exclusive**: onboarding signal beats inventory signal until acknowledged. +- Storage is opt-in per-feature; if upstream ships its own onboarding for a feature, just don't wrap that affordance. + +Canonical example: scratchlist toggle in `web/src/components/AssistantChat/ComposerButtons.tsx` (`ScratchlistToggleButton`). + +## Critical Thinking + +1. Fix root cause (not band-aid). +2. Unsure: read more code; if still stuck, ask w/ short options. +3. Conflicts: call out; pick safer path. +4. Unrecognized changes: assume other agent; keep going; focus your changes. If it causes issues, stop + ask user. diff --git a/README.md b/README.md index e7adadb4..85af9a4f 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,17 @@ # HAPI -Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode / Kimi / Copilot / Antigravity / Pi / DeepSeek Harness sessions and control them remotely through native iOS / Android apps, Web / PWA, or Telegram Mini App. +Run official Claude Code / Codex / Cursor Agent / DeepSeek Harness / Grok Build / OpenCode / Kimi / Copilot / Antigravity / Pi sessions locally and control them remotely through a Web / PWA / Telegram Mini App. > **Why HAPI?** HAPI is a local-first alternative to Happy. See [Why Not Happy?](docs/guide/why-hapi.md) for the key differences. ## Features - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. -- **Shared Codex Sessions** - Use Codex from your terminal and phone at the same time. Requires Codex 0.154.0+. [Usage and limits](docs/guide/codex-shared-sessions.md). - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. -- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode, Kimi, Copilot, Antigravity, Pi, DeepSeek Harness—different agents, one unified workflow. -- **Terminal Anywhere** - Run commands from your phone's browser or desktop web app, directly connected to the working machine. -- **Voice Control** - Use dictation in native apps, or talk to your AI agent hands-free with the web voice assistant. +- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, DeepSeek Harness, Grok Build, OpenCode, Kimi, Copilot, Antigravity, Pi—different agents, one unified workflow. +- **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. +- **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. - **Workspace Browser** - Opt-in via one or more `hapi runner start --workspace-root ` flags: browse scoped file trees from the web and start sessions in allowed subdirectories. ## Demo @@ -23,16 +22,12 @@ https://github.com/user-attachments/assets/38230353-94c6-4dbe-9c29-b2a2cc457546 ```bash npx @twsxtd/hapi hub --relay # start hub with E2E encrypted relay -npx @twsxtd/hapi # choose an agent and start a session +npx @twsxtd/hapi # run claude code ``` `hapi server` remains supported as an alias. -Use `hapi [options]` to start an agent directly, for example `hapi claude` -or `hapi codex`. Scripts must specify the agent explicitly. `hapi --help` shows -HAPI's commands and supported agents. - -The hub displays a URL and two QR codes. Open the web URL in a browser, or pair a native app using the companion QR. See [Native apps](docs/guide/native-apps.md) for build and pairing instructions. +The terminal will display a URL and QR code. Scan the QR code with your phone or open the URL to access. > The relay uses WireGuard + TLS for end-to-end encryption. Your data is encrypted from your device to your machine. @@ -40,8 +35,7 @@ For self-hosted options (Cloudflare Tunnel, Tailscale), see [Installation](docs/ ## Docs -- [Native apps (iOS / Android)](docs/guide/native-apps.md) -- [Web / PWA](docs/guide/pwa.md) +- [App](docs/guide/pwa.md) - [How it Works](docs/guide/how-it-works.md) - [Supported Agents](docs/guide/agents.md) - [Voice Assistant](docs/guide/voice-assistant.md) @@ -50,12 +44,10 @@ For self-hosted options (Cloudflare Tunnel, Tailscale), see [Installation](docs/ ## Native apps (iOS / Android) -The repository includes SwiftUI/UIKit and Kotlin Compose clients with chat, approvals, session creation, files, dictation, and push notifications. See the [native app guide](docs/guide/native-apps.md) for capabilities, platform differences and pairing. Build instructions: [iOS](ios/README.md) and [Android](android/README.md). Developer protocol: [client contract](docs/api/client-contract/index.md). +Fully native SwiftUI and Kotlin Compose clients are in development under `ios/` and `android/`. They pair with your hub by scanning the same terminal QR code as the web app, and follow the same protocol — see the [client contract docs](docs/api/client-contract/index.md). ## Build from source -Requires Bun 1.4.0. - ```bash bun install bun run build:single-exe diff --git a/cli/README.md b/cli/README.md index 44f7a139..ec889e75 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,15 +1,15 @@ # hapi CLI -Choose a supported coding agent from your terminal and control its sessions remotely through the hapi hub. See the [supported agents](../docs/guide/agents.md). +Run Claude Code, Codex, Cursor Agent, DeepSeek Harness, Grok Build, or OpenCode sessions from your terminal and control them remotely through the hapi hub. ## What it does - Starts Claude Code sessions and registers them with hapi-hub. - Starts Codex mode for OpenAI-based sessions. - Starts Cursor Agent mode for Cursor CLI sessions. +- Connects to a running DeepSeek Harness Web host for native DSH sessions. - Starts Grok Build locally or via ACP for remote sessions. - Starts OpenCode mode via ACP and its plugin hook system. -- Starts DeepSeek Harness through an external ACP stdio server. - Provides an MCP stdio bridge for external tools. - Manages a background runner for long-running sessions. - Includes diagnostics and auth helpers. @@ -18,48 +18,28 @@ Choose a supported coding agent from your terminal and control its sessions remo 1. Start the hub and set env vars (see ../hub/README.md). 2. Set the same CLI_API_TOKEN on this machine or run `hapi auth login`. -3. Run `hapi` to choose an agent, or `hapi ` to start one directly. +3. Run `hapi` to start a session. 4. Use the web app or Telegram Mini App to monitor and control. ## Commands ### Session commands -- `hapi` - Choose an agent interactively. Unavailable agents are shown with a reason and cannot be selected. -- `hapi claude` - Start a Claude Code session (passes through Claude CLI flags). +- `hapi` - Start a Claude Code session (passes through Claude CLI flags). See `src/index.ts`. - `hapi codex` - Start Codex mode. See `src/codex/runCodex.ts`. -- `hapi codex resume ` - Resume a Codex conversation by its native thread ID. For a HAPI session ID, use `hapi resume `. +- `hapi codex resume ` - Resume existing Codex session. - `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`. Supports `hapi cursor resume `, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`. - Local and remote modes supported; new remote sessions use `agent acp`. Pre-ACP sessions retain the legacy `agent -p` stream-json resume path. + Local and remote modes supported; remote uses `agent -p` with stream-json. +- `hapi dsh` - Start or resume a DeepSeek Harness session through `dsh web`. + Start DSH first with `dsh web --port 3080`. HAPI uses `http://127.0.0.1:3080` by default; set `HAPI_DSH_URL` to override it. - `hapi grok` - Start Grok Build mode. See `src/grok/runGrok.ts`. -- `hapi copilot` - Start GitHub Copilot mode. -- `hapi kimi` - Start Kimi mode. -- `hapi agy` - Start Antigravity mode (remote-only). -- `hapi pi` - Start Pi mode (remote-only). - `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. Note: OpenCode supports local and remote modes; local mode streams via OpenCode plugins. -- `hapi dsh` - Start DeepSeek Harness through ACP. See `src/dsh/runDsh.ts`. - DSH is remote-only and its ACP server must be configured separately. - `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally. - `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` / `list_peers` over reinventing JWT+curl. Also `--message-file` / `--list`. - `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`. `/sessions/` is a hub path, not a local file. Optional `--limit`. -The picker lists agents alphabetically by command name. Use Up/Down and Enter -to choose; Esc or Ctrl-C cancels. It appears on every bare invocation, even -when only one agent is available. No default agent or selection is saved. - -Scripts and non-interactive shells must specify an agent. Old implicit-Claude -commands such as `hapi --yolo`, `hapi --resume`, and `hapi "prompt"` now report -an error; use `hapi claude --yolo`, `hapi claude --resume`, or -`hapi claude "prompt"` instead. - -`hapi --help`, `hapi -h`, and `hapi help` show only HAPI's help, without starting -a session or requiring an installed agent. `hapi --version` / `hapi -v` show -HAPI's version. Flags after an agent name are handled by that agent's existing -integration; supported flags vary by agent. HAPI does not translate or append -agent help text. - ### Resume a remote session locally ```bash @@ -69,32 +49,6 @@ hapi resume `hapi resume` lists resumable sessions for the current machine. `hapi resume ` hands off an active remote session and opens the same HAPI session in the local terminal. -For Codex, the terminal and Web stay usable at the same time. Closing the -original terminal stops a terminal-started run, but its history remains -resumable. Sessions started from the Web run under the Runner; closing a -terminal attached later does not stop them. **End session** archives the -selected conversation. See [Codex usage and limits](../docs/guide/codex-shared-sessions.md) -for details. - -### Answer local Claude prompts from HAPI - -In a local Claude session started by `hapi claude`, main-session `AskUserQuestion` -questions and tool permission prompts can also be answered from the web app. -The terminal dialog stays usable; answering does not restart Claude or switch -the session to remote mode. Claude arbitrates terminal/web races, and HAPI -records the native result rather than assuming the web response won. - -The bridge uses Claude's `PermissionRequest` hook, not a blocking -`PreToolUse` approval gate. An unanswered remote request expires after one -hour; expiration, disconnection, or bridge failure leaves the native prompt -available. Local answers/cancellation, session changes, and mode switches -withdraw stale web controls. Cancellation detection may wait for the next -transcript scan. - -Verified with Claude Code **2.1.221**. Background subagents, `ExitPlanMode`, -and requests that cannot be unambiguously matched to a native tool call remain -terminal-only. Earlier Claude versions have not been verified. - ### Authentication - `hapi auth status` - Show authentication configuration and token source. @@ -105,9 +59,8 @@ See `src/commands/auth.ts`. ### Runner management -- `hapi runner start` - Replace any existing runner and start a detached process with the supplied flags/environment. -- `hapi runner stop` - Stop runner gracefully; agent sessions stay alive. -- `hapi runner start-sync` - Run in the foreground (for a process supervisor). +- `hapi runner start` - Start runner as detached process. +- `hapi runner stop` - Stop runner gracefully. - `hapi runner status` - Show runner diagnostics. - `hapi runner list` - List active sessions managed by runner. - `hapi runner stop-session ` - Terminate specific session. @@ -119,10 +72,7 @@ Both `start` and `start-sync` accept repeatable `--workspace-root ` (or `- - The runner refuses `list-directory` and `spawn-session` requests for paths outside the configured roots. - `~` and `~/foo` are expanded. -Omitting the flag keeps manual session spawning unrestricted and leaves the -web `/browse` feature disabled. Machine directory lookups used by session -autocomplete and native pickers are still available, but are limited to the -runner's home directory. +Omitting the flag keeps the legacy behavior: no scoping, no `/browse` feature. See `src/runner/run.ts`. @@ -139,74 +89,27 @@ See `src/ui/doctor.ts`. - `hapi hub` - Start the bundled hub (single binary workflow). - `hapi server` - Alias for `hapi hub`. -### Codex MCP servers - -Codex sessions keep the MCP servers configured in the user's Codex -`config.toml`. HAPI adds its own `hapi` bridge without replacing other user -servers. When a runner spawn supplies a Codex auth token, it copies only -`config.toml` into a temporary `CODEX_HOME` and writes the supplied `auth.json`, -preserving MCP settings without copying unrelated authentication state. -Without a supplied token, Codex uses the runner's normal Codex home/auth. -The `hapi` server name is reserved by HAPI. - -On Windows, known package-manager shims (`uvx`, `npx`, `npm`, `pnpm`, `yarn`, -`bunx`, and `.cmd`/`.bat` commands) use a short-lived HAPI stdio compatibility -proxy before reaching the configured MCP server. The proxy keeps the original -command and arguments in a session-temporary file and forwards MCP JSON-RPC -bytes without putting environment-variable values into arguments or that file. -Secret and network variables still need to be listed in the MCP entry's -`env_vars` (or supplied through `env`); HAPI does not forward the whole host -environment automatically. - ## Configuration See `src/configuration.ts` for all options. -DeepSeek Harness ACP uses `dsh-acp-demo` by default. Override the executable or -its arguments without shell parsing: - -```bash -export HAPI_DSH_ACP_COMMAND=dsh-acp-demo -export HAPI_DSH_ACP_CONFIG=/path/to/deepseek-harness/examples/acp-agent/cordis.yml -hapi dsh -``` - -For a source checkout, use JSON arguments: - -```bash -export HAPI_DSH_ACP_COMMAND=pnpm -export HAPI_DSH_ACP_ARGS_JSON='["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]' -``` - -The official ACP demo is fresh-session-only and does not support native resume, -model switching, MCP injection, or live tool/reasoning telemetry. HAPI uses the -standard chat and pending one-shot permission surfaces; the ACP composition -owns the overall permission policy and HAPI does not advertise resume or model -controls for DSH. - ### Required - `CLI_API_TOKEN` - Shared secret; must match the hub. Can be set via env or `~/.hapi/settings.json` (env wins). +- `HAPI_API_URL` - Hub base URL (default: http://localhost:3006). ### Optional -- `HAPI_API_URL` - Hub base URL (default: http://localhost:3006; also configurable as `apiUrl` in settings). - `HAPI_HOME` - Config/data directory (default: ~/.hapi). - `HAPI_EXPERIMENTAL` - Enable experimental features (true/1/yes). - `HAPI_EXTRA_HEADERS_JSON` - JSON object of extra headers to send on CLI → hub requests, e.g. `{"Cookie":"CF_Authorization=..."}`. Can also be set as the `extraHeaders` object in `~/.hapi/settings.json` (environment variable wins). - `HAPI_CLAUDE_PATH` - Path to a specific `claude` executable. -- `HAPI_DSH_ACP_COMMAND` - ACP server executable for `hapi dsh` (default: `dsh-acp-demo`). -- `HAPI_DSH_ACP_CONFIG` - Optional `dsh-acp-demo --config` path. -- `HAPI_DSH_ACP_ARGS_JSON` - Optional JSON array of ACP server arguments. - `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`. ### Runner - `HAPI_RUNNER_HEARTBEAT_INTERVAL` - Heartbeat interval in ms (default: 60000). - `HAPI_RUNNER_HTTP_TIMEOUT` - HTTP timeout for runner control in ms (default: 10000). -- `HAPI_RUNNER_WEBHOOK_TIMEOUT_MS` - Session-start webhook timeout in ms (default: 15000); raise for slow agent startup/resume. -- `HAPI_DISABLE_VERSION_HANDOFF` - Set to `1` to disable automatic runner replacement on CLI binary changes. -- `HAPI_RUNNER_SUPERVISED` - Set to `1` only when a supervisor restarts the runner after exit; enables the web Restart control's supervised path. ### Worktree (set by runner) @@ -218,28 +121,17 @@ controls for DSH. ### Set for the wrapped agent -- `HAPI_SESSION_ID` - The current HAPI session ID, available inside agent shells. Use it in scripts that target the current conversation without listing sessions. -- An explicitly configured `HAPI_API_URL` is also made available to agent shells. HAPI does not copy settings-backed `CLI_API_TOKEN` secrets into the agent environment; credentials already present in the parent environment may still be inherited. Web terminal PTYs strip hub secrets. +- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / copilot / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not. To **list** peers on the same hub/namespace, prefer MCP `list_peers` (works from runner-spawned sessions without sitting on the hub host; excludes the calling session). To **read** another session, prefer MCP `inspect_peer` or `hapi inspect-peer`. To **message** another session, prefer MCP `ping_peer` or `hapi ping-peer` — do not reinvent JWT+curl. User citations look like `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`; pass that `` as `sessionIdPrefix`. Do not Grep/Glob `/sessions/` as a local filesystem path. On a remote runner, configure matching `HAPI_API_URL` + `CLI_API_TOKEN` (or `hapi auth login` / `~/.hapi/settings.json`) on the runner host so shell `hapi ping-peer --list` works; session CLI may export an explicit non-default hub URL into child env, but never mirrors `CLI_API_TOKEN` into wrapped agents. -For peer discovery and messaging, use the session's MCP `list_peers`, -`inspect_peer`, and `ping_peer` tools, or the corresponding CLI commands. -On a remote runner host, configure the matching hub URL and token so shell -commands reach the same hub (`hapi auth login` saves the token). + Lazy Codex (terminal) sessions export the id only after the hub row is materialized, which happens when the MCP bridge starts — before the agent process is spawned — so path-only self-targeting does not race a missing hub row. -For example, this source-checkout helper displays an image in the current -session when MCP is unavailable: + Example (shell fallback when MCP is unavailable) — path-only, self-targets the current session: -```bash -bun scripts/tooling/hapi-display-image.mjs /absolute/path/to/image.png "optional title" -``` + ```bash + bun scripts/tooling/hapi-display-image.mjs /absolute/path/to/image.png "optional title" + ``` -## Session lifecycle invariants - -When changing agent bootstrap, handoff, or shared-session plumbing: - -- Handoff-capable integrations use `local` (terminal) and `remote` (web-controlled) ownership modes. Codex instead supports concurrent clients without ownership switching; see [Codex shared sessions](../docs/guide/codex-shared-sessions.md). -- Ordinary wrappers export `HAPI_SESSION_ID` after bootstrap. Shared Codex uses a per-root MCP bridge and `shell_environment_policy.set.HAPI_SESSION_ID`; never put one root's ID into the shared app-server environment. Implementation: `src/codex/shared/root.ts`, `src/codex/shared/runtime.ts`. -- Gemini remains a historical wire flavor, not a launchable integration. Use the [supported-agent guide](../docs/guide/agents.md) for the launchable set. + Explicit other session (prefix or full uuid) still works; that path may list sessions. ## Storage @@ -251,11 +143,12 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`): ## Requirements -- Install and authenticate the agent you want to use. Claude CLI (`claude` on PATH) is required only for `hapi claude`. +- Claude CLI installed and logged in (`claude` on PATH). - Cursor Agent CLI installed (`agent` on PATH) for `hapi cursor`. Install: `curl https://cursor.com/install -fsS | bash` (macOS/Linux), `irm 'https://cursor.com/install?win32=true' | iex` (Windows). - Grok Build CLI installed (`grok` on PATH) for `hapi grok`. Authenticate with `grok login --device-auth` on headless runner machines, or set `XAI_API_KEY`. - OpenCode CLI installed (`opencode` on PATH). -- Bun 1.4.0 for building from source. +- DeepSeek Harness installed with `dsh web` running for `hapi dsh`. +- Bun for building from source. ## Build from source @@ -263,8 +156,8 @@ From the repo root: ```bash bun install -bun run build:cli # Type-check the CLI; no executable output -bun run --cwd cli build:exe # Host-platform executable in cli/dist-exe// +bun run build:cli +bun run build:cli:exe ``` For an all-in-one binary that also embeds the web app: @@ -275,10 +168,11 @@ bun run build:single-exe ## Source structure -- `src/api/` - Hub communication (Socket.IO + REST). +- `src/api/` - Bot communication (Socket.IO + REST). - `src/claude/` - Claude Code integration. - `src/codex/` - Codex mode integration. - `src/cursor/` - Cursor Agent integration. +- `src/dsh/` - DeepSeek Harness Web RPC, history import, and live session integration. - `src/grok/` - Grok Build native TUI + ACP integration. - `src/agent/` - Shared support for ACP-compatible agents. - `src/opencode/` - OpenCode ACP + hook integration. diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index 7222e6d4..3fd86a08 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AgentMessage } from '@/agent/types'; import { AcpSdkBackend } from './AcpSdkBackend'; -import { AcpMessageHandler } from './AcpMessageHandler'; import { buildAcpStdioSpawnOptions } from './AcpStdioTransport'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; @@ -210,7 +209,7 @@ describe('AcpSdkBackend', () => { ]); }); - it('uses session/set_config_option when flavor is opencode and captures thought_level', async () => { + it('uses session/set_model when flavor is opencode', async () => { const backend = new AcpSdkBackend({ command: 'opencode' }); const calls: Array<{ method: string; params: unknown }> = []; const backendInternal = backend as unknown as { @@ -219,50 +218,25 @@ describe('AcpSdkBackend', () => { backendInternal.transport = { sendRequest: async (method, params) => { calls.push({ method, params }); - if (method === 'session/set_config_option') { - return { - models: { - currentModelId: 'opencode/hy3-free', - availableModels: [] - }, - configOptions: [{ - id: 'effort', - category: 'thought_level', - currentValue: 'high', - options: [ - { value: 'low', name: 'Low' }, - { value: 'medium', name: 'Medium' }, - { value: 'high', name: 'High' } - ] - }] - }; - } - return null; + // OpenCode 1.14.30's set_model response: only an opaque _meta block. + return { + _meta: { opencode: { modelId: 'ollama/exaone:4.5-33b-q8', variant: null, availableVariants: [] } } + }; }, close: async () => {} }; - await backend.setModel('session-1', 'opencode/hy3-free', { flavor: 'opencode' }); + await backend.setModel('session-1', 'ollama/exaone:4.5-33b-q8', { flavor: 'opencode' }); expect(calls).toEqual([ { - method: 'session/set_config_option', + method: 'session/set_model', params: { sessionId: 'session-1', - configId: 'model', - value: 'opencode/hy3-free' + modelId: 'ollama/exaone:4.5-33b-q8' } } ]); - expect(backend.getThoughtLevelConfigOption('session-1')).toMatchObject({ - id: 'effort', - currentValue: 'high', - options: [ - { value: 'low', name: 'Low' }, - { value: 'medium', name: 'Medium' }, - { value: 'high', name: 'High' } - ] - }); }); it('captures availableModels and currentModelId from session/new response', async () => { @@ -474,9 +448,8 @@ describe('AcpSdkBackend', () => { expect(backend.getSessionModelsMetadata(sessionId)).toBeUndefined(); }); - it('falls back to session/set_model when opencode set_config_option is not found', async () => { + it('optimistically updates currentModelId after a successful opencode setModel call', async () => { const backend = new AcpSdkBackend({ command: 'opencode' }); - const calls: Array<{ method: string; params: unknown }> = []; const backendInternal = backend as unknown as { transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; }; @@ -485,23 +458,13 @@ describe('AcpSdkBackend', () => { { modelId: 'ollama/b', name: 'b' } ]; backendInternal.transport = { - sendRequest: async (method, params) => { - calls.push({ method, params }); + sendRequest: async (method) => { if (method === 'session/new') { return { sessionId: 's1', - models: { availableModels: fixtureModels, currentModelId: 'ollama/a' }, - configOptions: [{ - id: 'effort', - category: 'thought_level', - currentValue: 'high', - options: [{ value: 'high', name: 'High' }] - }] + models: { availableModels: fixtureModels, currentModelId: 'ollama/a' } }; } - if (method === 'session/set_config_option') { - throw new Error('Method not found'); - } if (method === 'session/set_model') { // OpenCode 1.14.30: response carries only an opaque _meta block. return { _meta: { opencode: { modelId: 'ollama/b' } } }; @@ -512,60 +475,14 @@ describe('AcpSdkBackend', () => { }; await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); - expect(backend.getThoughtLevelConfigOption('s1')).toBeDefined(); await backend.setModel('s1', 'ollama/b', { flavor: 'opencode' }); - expect(calls.slice(1)).toEqual([ - { - method: 'session/set_config_option', - params: { sessionId: 's1', configId: 'model', value: 'ollama/b' } - }, - { method: 'session/set_model', params: { sessionId: 's1', modelId: 'ollama/b' } } - ]); + // availableModels list is preserved from session/new; currentModelId is + // optimistically updated from the requested modelId. expect(backend.getSessionModelsMetadata('s1')).toEqual({ availableModels: fixtureModels, currentModelId: 'ollama/b' }); - expect(backend.getThoughtLevelConfigOption('s1')).toBeUndefined(); - }); - - it('rethrows non method-not-found errors from opencode set_config_option without falling back', async () => { - const backend = new AcpSdkBackend({ command: 'opencode' }); - const backendInternal = backend as unknown as { - transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; - }; - backendInternal.transport = { - sendRequest: async (method) => { - if (method === 'session/set_config_option') { - throw new Error('Invalid params'); - } - return null; - }, - close: async () => {} - }; - - await expect(backend.setModel('session-1', 'm/b', { flavor: 'opencode' })).rejects.toThrow('Invalid params'); - }); - - it('uses session/set_model when flavor is grok', async () => { - const backend = new AcpSdkBackend({ command: 'grok' }); - const calls: Array<{ method: string; params: unknown }> = []; - const backendInternal = backend as unknown as { - transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; - }; - backendInternal.transport = { - sendRequest: async (method, params) => { - calls.push({ method, params }); - return null; - }, - close: async () => {} - }; - - await backend.setModel('session-1', 'grok-4.5', { flavor: 'grok' }); - - expect(calls).toEqual([ - { method: 'session/set_model', params: { sessionId: 'session-1', modelId: 'grok-4.5' } } - ]); }); @@ -770,6 +687,80 @@ describe('AcpSdkBackend', () => { }); }); + it('sends OpenCode native prompts with a model variant and forwards ACP updates', async () => { + backendStatics.UPDATE_QUIET_PERIOD_MS = 1; + backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 20; + backendStatics.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 1; + backendStatics.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 20; + backendStatics.LATE_FLUSH_INTERVAL_MS = 1; + backendStatics.LATE_FLUSH_QUIET_PERIOD_MS = 1; + backendStatics.LATE_FLUSH_WINDOW_MS = 20; + + const backend = new AcpSdkBackend({ command: 'opencode' }); + const backendInternal = backend as unknown as { + transport: { + sendRequest: (...args: unknown[]) => Promise; + close: () => Promise; + } | null; + handleSessionUpdate: (params: unknown) => void; + }; + backendInternal.transport = { + sendRequest: async () => ({}), + close: async () => {} + }; + + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + expect(body.model).toEqual({ providerID: 'opencode-go', modelID: 'deepseek-v4.1-flash' }); + expect(body.variant).toBe('max'); + expect(body.parts).toEqual([{ type: 'text', text: 'hello' }]); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text: 'OK' } + } + }); + return new Response(JSON.stringify({ + info: { + finish: 'stop', + tokens: { input: 12, output: 2, reasoning: 5, total: 14, cache: { read: 3, write: 1 } } + } + }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchMock); + + try { + const messages: AgentMessage[] = []; + await backend.promptWithVariant('session-1', [{ type: 'text', text: 'hello' }], (message) => { + messages.push(message); + }, { + baseUrl: 'http://127.0.0.1:1234', + directory: '/workspace', + providerId: 'opencode-go', + modelId: 'deepseek-v4.1-flash', + variant: 'max' + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(messages).toContainEqual({ type: 'text', text: 'OK' }); + expect(messages).toContainEqual({ + type: 'usage', + inputTokens: 12, + outputTokens: 2, + totalTokens: 14, + thoughtTokens: 5, + cacheReadTokens: 3, + cacheCreationTokens: 1, + contextTokens: undefined, + contextWindow: undefined + }); + expect(messages).toContainEqual({ type: 'turn_complete', stopReason: 'stop' }); + } finally { + vi.unstubAllGlobals(); + } + }); + it('emits straggler chunks before turn_complete', async () => { backendStatics.UPDATE_QUIET_PERIOD_MS = 5; backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 50; @@ -1333,7 +1324,7 @@ describe('AcpSdkBackend', () => { transport: { sendRequestWithDispatch: () => { dispatched: Promise; completed: Promise }; close: () => Promise } | null; isProcessingMessage: boolean; activePromptRequests: number; - finishPromptRequest: (epoch: number) => void; + finishPromptRequest: () => void; waitForSessionUpdateQuiet: () => Promise; drainLateBuffers: () => Promise; }; @@ -1353,7 +1344,7 @@ describe('AcpSdkBackend', () => { let responseComplete = false; const responseWait = backend.waitForResponseComplete().then(() => { responseComplete = true; }); - backendInternal.finishPromptRequest(0); + backendInternal.finishPromptRequest(); await Promise.resolve(); expect(backend.processingMessage).toBe(true); expect(responseComplete).toBe(false); @@ -1412,7 +1403,6 @@ describe('AcpSdkBackend', () => { .toThrow(/No active ACP prompt/); }); - it('suppressUpdatesDuring drops session/update notifications that would otherwise leak into the previous turn\'s onUpdate, then restores normal forwarding', async () => { // Reproduces the real /compact duplicate-summary bug: OpenCode keeps // streaming session/update notifications (over the same ACP @@ -1811,57 +1801,3 @@ describe('AcpSdkBackend', () => { await pending; }); }); - -describe('AcpSdkBackend abortSoftSteers', () => { - it('drains buffered foreground output before suppressing late updates', async () => { - const backend = new AcpSdkBackend({ command: 'agent' }); - const updates: AgentMessage[] = []; - const handler = new AcpMessageHandler((message) => updates.push(message)); - const backendInternal = backend as unknown as { - messageHandler: AcpMessageHandler | null; - }; - - await handler.handleUpdate({ - sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, - content: { type: 'text', text: 'partial answer' } - }); - await handler.handleUpdate({ - sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, - content: { type: 'text', text: 'partial thought' } - }); - backendInternal.messageHandler = handler; - - backend.abortSoftSteers(); - - expect(updates).toEqual([ - { type: 'reasoning', text: 'partial thought' }, - { type: 'text', text: 'partial answer' } - ]); - }); - - it('releases processingMessage without waiting for the concurrent prompt', () => { - const backend = new AcpSdkBackend({ command: 'agent' }); - const backendInternal = backend as unknown as { - isProcessingMessage: boolean; - activePromptRequests: number; - }; - backendInternal.isProcessingMessage = true; - backendInternal.activePromptRequests = 2; - - backend.abortSoftSteers(); - - expect(backend.processingMessage).toBe(false); - expect(backendInternal.activePromptRequests).toBe(0); - }); - - it('is a no-op when nothing is in flight', () => { - const backend = new AcpSdkBackend({ command: 'agent' }); - const backendInternal = backend as unknown as { - activePromptRequests: number; - }; - backendInternal.activePromptRequests = 0; - - expect(() => backend.abortSoftSteers()).not.toThrow(); - expect(backend.processingMessage).toBe(false); - }); -}); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index ee388e6d..809ff579 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -27,6 +27,21 @@ type AcpUsageUpdate = { contextWindow: number | undefined; }; +type PromptRequestResult = { + stopReason: string | null; + usage: AcpPromptUsage | null; +}; + +type OpenCodeNativePromptOptions = { + baseUrl: string; + directory: string; + providerId: string; + modelId: string; + variant: string; +}; + +type BunFetchInit = RequestInit & { timeout?: false }; + export type AcpModelDescriptor = { modelId: string; name?: string; @@ -77,19 +92,14 @@ export class AcpSdkBackend implements AgentBackend { private initializeInFlight: Promise | null = null; private setModeSupported: boolean | undefined = undefined; private isProcessingMessage = false; - private promptRequestInFlight = false; - /** Concurrent session/prompt requests (main prompt + soft steers). */ private activePromptRequests = 0; - /** Foreground prompt only; soft steers are excluded after Abort. */ - private foregroundPromptRequests = 0; - /** Bumped by abortSoftSteers; stale finishes from cancelled requests are dropped. */ - private promptRequestEpoch = 0; - /** Incremented for each foreground prompt turn, including retry-wrapped turns. */ - private promptGeneration = 0; + private promptRequestInFlight = false; private responseCompleteResolvers: Array<() => void> = []; private lastSessionUpdateAt = 0; private latestUsageUpdate: AcpUsageUpdate | null = null; private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; + private nativePromptAbortController: AbortController | null = null; + private nativePromptAbortOptions: OpenCodeNativePromptOptions | null = null; private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null; /** Fired on foreground ACP state / permission so launchers can bump hub thinking (#1470). */ @@ -393,57 +403,17 @@ export class AcpSdkBackend implements AgentBackend { // exposed as `unstable_setSessionModel` but the JSON-RPC method on the wire // is unprefixed). Errors (including JSON-RPC 'method not found') propagate // as rejections from the transport; the launcher's catch block handles them. - let configOptionResponse: unknown; - let usedConfigOption = false; - if (opts?.flavor === 'opencode') { - // OpenCode's `session/set_model` response only carries an opaque - // `_meta` block with no `configOptions`, so the per-session - // thought_level options captured at session/new go stale after an - // inline switch. `session/set_config_option` with configId "model" - // (OpenCode's fixed id for the model picker) echoes fresh - // `configOptions` including thought_level for the new model. - try { - configOptionResponse = await this.transport.sendRequest('session/set_config_option', { - sessionId, - configId: 'model', - value: modelId - }); - usedConfigOption = true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - // Older OpenCode builds predate set_config_option — fall back to - // the legacy set_model path below. Any other error propagates to - // the launcher's existing catch handling. - if (!/method not found/i.test(message)) { - throw error; - } - } - } + const response = await this.transport.sendRequest('session/set_model', { + sessionId, + modelId + }); - const response = usedConfigOption - ? configOptionResponse - : await this.transport.sendRequest('session/set_model', { - sessionId, - modelId - }); - - if (usedConfigOption) { - this.captureSessionMetadata(sessionId, response); - } else if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { + if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { // OpenCode's set_model response only carries an opaque `_meta` block, // not `availableModels`/`currentModelId`. Optimistically update the // cached currentModelId (the call succeeded, so the agent has switched) // while preserving the availableModels list captured from session/new. this.updateCurrentModelOptimistic(sessionId, modelId); - if (opts.flavor === 'opencode') { - const options = this.sessionConfigOptions.get(sessionId); - if (options) { - this.sessionConfigOptions.set( - sessionId, - options.filter((option) => option.category !== 'thought_level') - ); - } - } } else { // For other flavors (e.g. Gemini), if the response carries metadata, // capture it. Missing fields are silently ignored. @@ -583,6 +553,98 @@ export class AcpSdkBackend implements AgentBackend { sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void + ): Promise { + return await this.runPrompt(sessionId, content, onUpdate, async () => { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + + let response: unknown; + response = await this.transport.sendRequest('session/prompt', { + sessionId, + prompt: content + }, { timeoutMs: Infinity }); + + return { + stopReason: isObject(response) ? asString(response.stopReason) : null, + usage: this.extractPromptUsage(response) + }; + }); + } + + /** + * Sends an OpenCode prompt through its native HTTP API with an explicit + * model variant. OpenCode's ACP adapter currently ignores a `variant` + * field on `session/prompt`, while the native endpoint applies it and still + * broadcasts the normal `session/update` notifications over ACP. Keeping + * the handler lifecycle in this backend means tool/reasoning streaming, + * permissions, usage and turn boundaries remain identical to ACP prompts. + */ + async promptWithVariant( + sessionId: string, + content: PromptContent[], + onUpdate: (msg: AgentMessage) => void, + options: OpenCodeNativePromptOptions + ): Promise { + return await this.runPrompt(sessionId, content, onUpdate, async () => { + const controller = new AbortController(); + this.nativePromptAbortController = controller; + this.nativePromptAbortOptions = options; + try { + const url = `${options.baseUrl}/session/${encodeURIComponent(sessionId)}/message?directory=${encodeURIComponent(options.directory)}`; + const init: BunFetchInit = { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: { + providerID: options.providerId, + modelID: options.modelId + }, + variant: options.variant, + parts: content + }), + // Bun's default fetch timeout is too short for long agent + // turns. The AbortController remains the user-driven stop. + timeout: false, + signal: controller.signal + }; + const response = await (fetch as (url: string, init?: RequestInit) => Promise)(url, init); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`OpenCode prompt failed (${response.status}): ${text.slice(0, 300)}`); + } + + const payload: unknown = await response.json().catch(() => null); + if (!isObject(payload)) { + throw new Error('OpenCode prompt returned an invalid response'); + } + if (isObject(payload) && isObject(payload.info) && payload.info.error) { + const error = payload.info.error; + const message = isObject(error) && isObject(error.data) && typeof error.data.message === 'string' + ? error.data.message + : typeof error === 'string' ? error : 'OpenCode reported a prompt error.'; + throw new Error(message); + } + + const info = isObject(payload) && isObject(payload.info) ? payload.info : null; + return { + stopReason: asString(info?.finish) ?? (isObject(payload) ? asString(payload.stopReason) : null), + usage: this.extractPromptUsage(payload) + }; + } finally { + if (this.nativePromptAbortController === controller) { + this.nativePromptAbortController = null; + this.nativePromptAbortOptions = null; + } + } + }); + } + + private async runPrompt( + sessionId: string, + content: PromptContent[], + onUpdate: (msg: AgentMessage) => void, + request: () => Promise ): Promise { if (!this.transport) { throw new Error('ACP transport not initialized'); @@ -605,9 +667,7 @@ export class AcpSdkBackend implements AgentBackend { textChunkMode: this.options.textChunkMode, flavor: this.options.flavor, }); - this.promptGeneration++; - this.foregroundPromptRequests++; - const promptRequestEpoch = this.beginPromptRequest(); + this.beginPromptRequest(); this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; this.lastForwardedUsageUpdate = null; @@ -616,22 +676,12 @@ export class AcpSdkBackend implements AgentBackend { let promptUsage: AcpPromptUsage | null = null; try { - // No timeout for prompt requests - they can run for extended periods - // during complex tasks, tool-heavy operations, or slow model responses this.promptRequestInFlight = true; - let response: unknown; - try { - response = await this.transport.sendRequest('session/prompt', { - sessionId, - prompt: content - }, { timeoutMs: Infinity }); - } finally { - this.promptRequestInFlight = false; - } - - stopReason = isObject(response) ? asString(response.stopReason) : null; - promptUsage = this.extractPromptUsage(response); + const result = await request(); + stopReason = result.stopReason; + promptUsage = result.usage; } finally { + this.promptRequestInFlight = false; await this.waitForSessionUpdateQuiet( AcpSdkBackend.UPDATE_QUIET_PERIOD_MS, AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS @@ -685,19 +735,24 @@ export class AcpSdkBackend implements AgentBackend { } } finally { this.promptUsageCallback = null; - this.foregroundPromptRequests = Math.max(0, this.foregroundPromptRequests - 1); - if (promptRequestEpoch !== this.promptRequestEpoch) { - this.activePromptRequests = Math.max(0, this.activePromptRequests - 1); - this.isProcessingMessage = this.activePromptRequests > 0; - if (!this.isProcessingMessage) this.notifyResponseComplete(); - } else { - this.finishPromptRequest(promptRequestEpoch); - } + this.finishPromptRequest(); } } } async cancelPrompt(sessionId: string): Promise { + const nativeController = this.nativePromptAbortController; + const nativeOptions = this.nativePromptAbortOptions; + if (nativeController && nativeOptions) { + // OpenCode's ACP implementation does not expose session/cancel, + // but its native HTTP API does. Abort the server-side turn first, + // then release the local fetch so the launcher can leave remote + // mode without waiting for the provider response. + const url = `${nativeOptions.baseUrl}/session/${encodeURIComponent(sessionId)}/abort?directory=${encodeURIComponent(nativeOptions.directory)}`; + void fetch(url, { method: 'POST' }).catch(() => undefined); + nativeController.abort(); + return; + } if (!this.transport) { return; } @@ -724,14 +779,14 @@ export class AcpSdkBackend implements AgentBackend { throw new Error('No active ACP prompt to soft-steer into'); } - const promptRequestEpoch = this.beginPromptRequest(); + this.beginPromptRequest(); try { await this.transport.sendRequest('session/prompt', { sessionId, prompt: content }, { timeoutMs: Infinity }); } finally { - this.finishPromptRequest(promptRequestEpoch); + this.finishPromptRequest(); } } @@ -752,11 +807,11 @@ export class AcpSdkBackend implements AgentBackend { } const transport = this.transport; - const promptRequestEpoch = this.beginPromptRequest(); + this.beginPromptRequest(); const request = transport.sendRequestWithDispatch('session/prompt', { sessionId, prompt: content - }, { timeoutMs: Infinity, dispatchTimeoutMs: 20_000 }); + }, { timeoutMs: Infinity }); const completed = (async () => { try { await request.completed; @@ -770,7 +825,7 @@ export class AcpSdkBackend implements AgentBackend { await this.drainLateBuffers(); this.messageHandler?.drainBuffers(); } finally { - this.finishPromptRequest(promptRequestEpoch); + this.finishPromptRequest(); } } })(); @@ -890,10 +945,6 @@ export class AcpSdkBackend implements AgentBackend { return this.promptRequestInFlight; } - getPromptGeneration(): number { - return this.promptGeneration; - } - getLastSessionUpdateAt(): number { return this.lastSessionUpdateAt; } @@ -925,7 +976,6 @@ export class AcpSdkBackend implements AgentBackend { this.messageHandler = null; this.activeSessionId = null; this.activePromptRequests = 0; - this.foregroundPromptRequests = 0; this.isProcessingMessage = false; this.sessionModelsMetadata.clear(); this.initialAvailableCommands.clear(); @@ -1206,35 +1256,12 @@ export class AcpSdkBackend implements AgentBackend { return await responsePromise; } - private beginPromptRequest(): number { + private beginPromptRequest(): void { this.activePromptRequests++; this.isProcessingMessage = true; - return this.promptRequestEpoch; } - /** - * Force-settle soft-steer bookkeeping without waiting for the concurrent - * `session/prompt` to finish. Called on abort: the in-flight turn is - * cancelled anyway, so a pending soft steer may never complete; dropping - * its counter keeps {@link waitForResponseComplete} from blocking the next - * turn. Bumps the epoch so a stale finish from a cancelled request cannot - * decrement a newer prompt's counter. - */ - abortSoftSteers(): void { - this.messageHandler?.drainBuffers(); - this.messageHandler?.deactivate?.(); - this.promptRequestEpoch++; - this.activePromptRequests = this.foregroundPromptRequests; - this.isProcessingMessage = this.activePromptRequests > 0; - if (!this.isProcessingMessage) { - this.notifyResponseComplete(); - } - } - - private finishPromptRequest(epoch: number): void { - if (epoch !== this.promptRequestEpoch) { - return; - } + private finishPromptRequest(): void { this.activePromptRequests = Math.max(0, this.activePromptRequests - 1); this.isProcessingMessage = this.activePromptRequests > 0; if (!this.isProcessingMessage) { @@ -1280,28 +1307,37 @@ export class AcpSdkBackend implements AgentBackend { } private extractPromptUsage(response: unknown): AcpPromptUsage | null { - if (!isObject(response) || !isObject(response.usage)) return null; - const usage = response.usage; - const inputTokens = this.asFiniteNumber(usage.inputTokens ?? usage.input_tokens); - const outputTokens = this.asFiniteNumber(usage.outputTokens ?? usage.output_tokens); + if (!isObject(response)) return null; + // ACP returns a top-level `usage` object. OpenCode's native HTTP + // prompt returns the same counters under `info.tokens` instead. + const usage = isObject(response.usage) + ? response.usage + : isObject(response.info) && isObject(response.info.tokens) + ? response.info.tokens + : null; + if (!usage) return null; + const inputTokens = this.asFiniteNumber(usage.inputTokens ?? usage.input_tokens ?? usage.input); + const outputTokens = this.asFiniteNumber(usage.outputTokens ?? usage.output_tokens ?? usage.output); if (inputTokens === null || outputTokens === null) return null; return { inputTokens, outputTokens, - totalTokens: this.asFiniteNumber(usage.totalTokens ?? usage.total_tokens) ?? undefined, - thoughtTokens: this.asFiniteNumber(usage.thoughtTokens ?? usage.thought_tokens) ?? undefined, + totalTokens: this.asFiniteNumber(usage.totalTokens ?? usage.total_tokens ?? usage.total) ?? undefined, + thoughtTokens: this.asFiniteNumber(usage.thoughtTokens ?? usage.thought_tokens ?? usage.reasoning) ?? undefined, cacheReadTokens: this.asFiniteNumber( usage.cachedReadTokens ?? usage.cached_read_tokens ?? usage.cachedInputTokens ?? usage.cached_input_tokens + ?? (isObject(usage.cache) ? usage.cache.read : undefined) ) ?? undefined, cacheCreationTokens: this.asFiniteNumber( usage.cachedWriteTokens ?? usage.cached_write_tokens ?? usage.cacheCreationInputTokens ?? usage.cache_creation_input_tokens + ?? (isObject(usage.cache) ? usage.cache.write : undefined) ) ?? undefined }; } diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts index 5013618a..f3e73dc8 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts @@ -74,7 +74,11 @@ vi.mock('node:child_process', () => ({ }, stdin: { end: (...args: unknown[]) => spawnState.stdinEnd(...args), - write: (chunk: string) => spawnState.stdinWrite(chunk) + write: (chunk: string, callback?: (error?: Error | null) => void) => { + const result = spawnState.stdinWrite(chunk); + callback?.(); + return result; + } }, on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { if (event === 'exit') { @@ -623,7 +627,11 @@ describe('AcpStdioTransport closed stdin writes', () => { }); const transport = await AcpStdioTransport.create({ command: 'gemini' }); - await expect(transport.sendRequest('initialize')).rejects.toThrow('WritableIterable is closed'); + const request = transport.sendRequestWithDispatch('initialize'); + await Promise.all([ + expect(request.dispatched).rejects.toThrow('WritableIterable is closed'), + expect(request.completed).rejects.toThrow('WritableIterable is closed') + ]); await expect(transport.sendRequest('session/new')).rejects.toThrow('WritableIterable is closed'); }); }); diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index e94cf840..2d34eb0c 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -16,20 +16,6 @@ import { } from './agentCliGuard'; import { matchesAcpHttp2Cancel, matchesAcpRetryBackoff } from './acpStderrErrors'; -/** Marks transport-level failures whose request outcome is unknown (unlike an - * explicit JSON-RPC error response). */ -export const ACP_INDETERMINATE_SYMBOL = Symbol('acp-indeterminate'); - -function markAcpIndeterminate(error: Error): Error { - Object.defineProperty(error, ACP_INDETERMINATE_SYMBOL, { value: true }); - return error; -} - -export function isAcpIndeterminateError(error: unknown): boolean { - return typeof error === 'object' && error !== null - && (error as Record)[ACP_INDETERMINATE_SYMBOL] === true; -} - interface JsonRpcRequest { jsonrpc: '2.0'; id: string | number | null; @@ -82,7 +68,6 @@ export class AcpStdioTransport { private readonly pending = new Map void; reject: (error: Error) => void; - rejectDispatched: (error: Error) => void; }>(); private readonly requestHandlers = new Map(); private notificationHandler: ((method: string, params: unknown) => void) | null = null; @@ -258,24 +243,19 @@ export class AcpStdioTransport { /** Default timeout for requests in milliseconds (2 minutes) */ static readonly DEFAULT_TIMEOUT_MS = 120_000; - async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number; dispatchTimeoutMs?: number }): Promise { + async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise { const request = this.sendRequestWithDispatch(method, params, options); void request.dispatched.catch(() => {}); return request.completed; } - /** - * Split a request into transport dispatch (stdin accepted) and completion - * (JSON-RPC response). Lets callers commit state once stdin accepted the - * request without waiting for the (possibly long-running) response. - */ sendRequestWithDispatch( method: string, params?: unknown, - options?: { timeoutMs?: number; dispatchTimeoutMs?: number } + options?: { timeoutMs?: number } ): { dispatched: Promise; completed: Promise } { if (this.closed || this.exited) { - const error = markAcpIndeterminate(this.closeError ?? this.exitError ?? new Error('ACP transport is closed')); + const error = this.closeError ?? this.exitError ?? new Error('ACP transport is closed'); return { dispatched: Promise.reject(error), completed: Promise.reject(error) }; } @@ -288,105 +268,53 @@ export class AcpStdioTransport { }; const timeoutMs = options?.timeoutMs ?? AcpStdioTransport.DEFAULT_TIMEOUT_MS; - const dispatchTimeoutMs = options?.dispatchTimeoutMs ?? timeoutMs; let timer: ReturnType | null = null; - let dispatchTimer: ReturnType | null = null; - let resolveDispatched!: () => void; - let rejectDispatched!: (error: Error) => void; let resolveCompleted!: (value: unknown) => void; let rejectCompleted!: (error: Error) => void; - const dispatched = new Promise((resolve, reject) => { - resolveDispatched = resolve; - rejectDispatched = reject; - }); const completed = new Promise((resolve, reject) => { resolveCompleted = resolve; rejectCompleted = reject; }); - let dispatchSettled = false; - - const clearTimers = () => { - if (timer) clearTimeout(timer); - if (dispatchTimer) clearTimeout(dispatchTimer); - }; - const failRequest = (error: Error) => { - this.pending.delete(id); - clearTimers(); - if (!dispatchSettled) { - dispatchSettled = true; - rejectDispatched(error); - } - rejectCompleted(error); - }; if (Number.isFinite(timeoutMs)) { timer = setTimeout(() => { if (this.pending.has(id)) { - failRequest(markAcpIndeterminate(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`))); + this.pending.delete(id); + rejectCompleted(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`)); } }, timeoutMs); timer.unref(); } - if (Number.isFinite(dispatchTimeoutMs)) { - dispatchTimer = setTimeout(() => { - if (this.pending.has(id) && !dispatchSettled) { - const error = markAcpIndeterminate(new Error(`ACP request '${method}' dispatch timed out after ${dispatchTimeoutMs}ms`)); - try { - this.process.stdin.destroy(); - } catch (destroyError) { - logger.debug('[ACP] Error destroying stalled stdin', destroyError); - } - this.markClosed(error); - } - }, dispatchTimeoutMs); - dispatchTimer.unref(); - } this.pending.set(id, { resolve: (value) => { - clearTimers(); - if (!dispatchSettled) { - dispatchSettled = true; - resolveDispatched(); - } + if (timer) clearTimeout(timer); resolveCompleted(value); }, reject: (error) => { - clearTimers(); - if (!dispatchSettled) { - dispatchSettled = true; - resolveDispatched(); - } + if (timer) clearTimeout(timer); rejectCompleted(error); - }, - rejectDispatched: (error) => { - if (!dispatchSettled) { - dispatchSettled = true; - rejectDispatched(error); - } } }); - try { - const serialized = JSON.stringify(payload); - this.process.stdin.write(`${serialized}\n`, (error) => { - if (error) { - const writeError = markAcpIndeterminate(error instanceof Error ? error : new Error(String(error))); - this.markClosed(writeError); - failRequest(writeError); - return; - } - if (!dispatchSettled) { - dispatchSettled = true; - if (dispatchTimer) clearTimeout(dispatchTimer); - resolveDispatched(); - } - }); - } catch (error) { - const writeError = error instanceof Error ? error : new Error(String(error)); - this.markClosed(writeError); - failRequest(writeError); - } + const dispatched = new Promise((resolve, reject) => { + try { + const serialized = JSON.stringify(payload); + this.process.stdin.write(`${serialized}\n`, (error) => { + if (error) { + const writeError = error instanceof Error ? error : new Error(String(error)); + this.markClosed(writeError); + reject(writeError); + return; + } + resolve(); + }); + } catch (error) { + const writeError = error instanceof Error ? error : new Error(String(error)); + this.markClosed(writeError); + reject(writeError); + } + }); return { dispatched, completed }; } @@ -574,10 +502,8 @@ export class AcpStdioTransport { } private rejectAllPending(error: Error): void { - const indeterminate = markAcpIndeterminate(error); - for (const { reject, rejectDispatched } of this.pending.values()) { - rejectDispatched(indeterminate); - reject(indeterminate); + for (const { reject } of this.pending.values()) { + reject(error); } this.pending.clear(); } diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index fb50342e..6db9cafe 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -143,6 +143,14 @@ describe('bootstrapExistingSession', () => { session.metadata = { ...existingMetadata, claudeSessionId: 'claude-thread-1', + claudeImportState: { + state: 'complete', + machineId: 'machine-1', + claudeSessionId: 'claude-thread-1', + sourceFile: '/tmp/claude-thread-1.jsonl', + startedAt: 100, + updatedAt: 200 + }, codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', @@ -189,6 +197,10 @@ describe('bootstrapExistingSession', () => { expect(result.metadata).toEqual(expect.objectContaining({ claudeSessionId: 'claude-thread-1', + claudeImportState: expect.objectContaining({ + state: 'complete', + claudeSessionId: 'claude-thread-1' + }), codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index 79a47ba1..8e566a05 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -132,9 +132,13 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.agySessionId !== undefined) preserved.agySessionId = metadata.agySessionId if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol + if (metadata.dshSessionId !== undefined) preserved.dshSessionId = metadata.dshSessionId + if (metadata.dshImportState !== undefined) preserved.dshImportState = metadata.dshImportState + if (metadata.dshHistoryLastEventSeq !== undefined) preserved.dshHistoryLastEventSeq = metadata.dshHistoryLastEventSeq if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId if (metadata.copilotSessionId !== undefined) preserved.copilotSessionId = metadata.copilotSessionId if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId + if (metadata.claudeImportState !== undefined) preserved.claudeImportState = metadata.claudeImportState if (metadata.piResumeAttempt !== undefined) preserved.piResumeAttempt = metadata.piResumeAttempt if (metadata.ptyResumeAttempt !== undefined) preserved.ptyResumeAttempt = metadata.ptyResumeAttempt if (metadata.preferredPermissionMode !== undefined) preserved.preferredPermissionMode = metadata.preferredPermissionMode diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 3c6df8e5..8ff67810 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -3,18 +3,23 @@ */ import { io, type Socket } from 'socket.io-client' -import { readdir, stat } from 'node:fs/promises' -import { join } from 'node:path' +import { readdir, realpath, stat } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path' import { logger } from '@/ui/logger' import { configuration } from '@/configuration' import type { ClientToServerEvents, ServerToClientEvents, Update, UpdateMachineBody } from '@hapi/protocol' import { ArchiveCodexSessionRpcRequestSchema, + ListClaudeSessionsRpcRequestSchema, ListCodexSessionsRpcRequestSchema, + ListDshSessionsRpcRequestSchema, ListPiSessionsRpcRequestSchema, type ArchiveCodexSessionRpcResponse, - type AgentAvailabilityResponse, + type ListClaudeSessionsRpcResponse, + type DshModelsResponse, type ListCodexSessionsRpcResponse, + type ListDshSessionsRpcResponse, type ListPiSessionsRpcResponse, type MachineDirectoryEntry, type MachineListDirectoryResponse, @@ -29,16 +34,11 @@ import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' -import { setAgyCatalogChangeListener } from '../modules/common/agyModels' import { listOpencodeModelsForCwd, type ListOpencodeModelsForCwdRequest, type ListOpencodeModelsForCwdResponse } from '../modules/common/opencodeModels' -import { - listOpencodeModelVariants, - type ListOpencodeModelVariantsResponse -} from '../modules/common/opencodeModelVariants' import { listGrokModelsForCwd, type ListGrokModelsForCwdRequest, @@ -52,16 +52,15 @@ import { import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { archiveLocalCodexSession, listLocalCodexSessionSummaries, listLocalCodexSessionsWithMessagesByIds } from '../modules/common/codexSessions' +import { listLocalClaudeSessionSummaries, listLocalClaudeSessionsWithMessagesByIds } from '../modules/common/claudeSessions' import { listLocalPiSessionSummaries, listLocalPiSessionsWithMessagesByIds } from '../modules/common/piSessions' +import { listDshSessions } from '@/dsh/dshSessions' +import { listDshModels } from '@/dsh/dshModels' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' import { collectMachineHealth } from '@/utils/machineHealth' import { inspectCursorChatStore } from '@/cursor/cursorChatStoreStatus' import { homedir } from 'node:os' import type { CursorChatStoreStatus } from '@hapi/protocol/apiTypes' -import { MachinePathPolicy } from './machinePathPolicy' -import { getAgentAvailabilityResponse } from '@/agent/agentAvailability' - -export { normalizeWindowsDriveRoot } from './machinePathPolicy' type MachineRpcHandlers = { spawnSession: (options: SpawnSessionOptions) => Promise @@ -84,6 +83,30 @@ interface CursorChatStoreStatusRequest { homeDir?: string } +export function normalizeWindowsDriveRoot(path: string): string { + return /^[A-Za-z]:$/.test(path) ? `${path}\\` : path +} + +function canonicalRealpathSync(path: string): string { + return normalizeWindowsDriveRoot(realpathSync.native(path)) +} + +function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined { + if (!paths?.length) { + return undefined + } + + const normalized = Array.from(new Set(paths.map((path) => { + try { + return canonicalRealpathSync(path) + } catch { + return normalizeWindowsDriveRoot(resolvePath(path)) + } + }))) + + return normalized.length > 0 ? normalized : undefined +} + function workspaceRootsEqual(left?: string[], right?: string[]): boolean { const normalizedLeft = left ?? [] const normalizedRight = right ?? [] @@ -104,17 +127,17 @@ export class ApiMachineClient { private keepAliveStartTimeout: ReturnType | null = null private rpcHandlerManager: RpcHandlerManager - private readonly pathPolicy: MachinePathPolicy + private readonly normalizedWorkspaceRoots: string[] | undefined constructor( private readonly token: string, private readonly machine: Machine, private readonly workspaceRoots?: string[] ) { - this.pathPolicy = new MachinePathPolicy({ - workspaceRoots, - homeDirectory: this.machine.metadata?.homeDir ?? homedir(), - }) + // Realpath roots once so all subsequent comparisons are against + // canonical, symlink-resolved locations. Falls back to lexical + // resolution if realpath fails so we still get protection. + this.normalizedWorkspaceRoots = normalizeWorkspaceRoots(workspaceRoots) this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.machine.id, @@ -123,44 +146,23 @@ export class ApiMachineClient { registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) - // Only the machine daemon answers `:listAgyModels`, so it is - // the one process that can tell the hub its catalog moved. - setAgyCatalogChangeListener(() => { - this.socket.emit('machine-agy-models-changed', { machineId: this.machine.id }) - }) - - this.rpcHandlerManager.registerHandler( - RPC_METHODS.AgentAvailability, - async () => getAgentAvailabilityResponse() - ) - this.rpcHandlerManager.registerHandler(RPC_METHODS.PathExists, async (params) => { const rawPaths = Array.isArray(params?.paths) ? params.paths : [] const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string'))) const exists: Record = {} - const outsideWorkspaceRoots: string[] = [] await Promise.all(uniquePaths.map(async (path) => { const trimmed = path.trim() if (!trimmed) return - const resolved = await this.pathPolicy.resolveForCheck(trimmed) - if (!this.pathPolicy.isWithinSpawnRoots(resolved)) { - exists[trimmed] = false - outsideWorkspaceRoots.push(trimmed) - return - } try { - const stats = await stat(resolved) + const stats = await stat(trimmed) exists[trimmed] = stats.isDirectory() } catch { exists[trimmed] = false } })) - return { - exists, - ...(outsideWorkspaceRoots.length > 0 ? { outsideWorkspaceRoots } : {}), - } + return { exists } }) this.rpcHandlerManager.registerHandler( @@ -176,6 +178,10 @@ export class ApiMachineClient { ) this.rpcHandlerManager.registerHandler(RPC_METHODS.ListMachineDirectory, async (params) => { + if (!this.normalizedWorkspaceRoots?.length) { + return { success: false, error: 'Workspace browsing is not enabled for this machine' } + } + const rawPath = typeof params?.path === 'string' ? params.path.trim() : '' if (!rawPath) { return { success: false, error: 'Path is required' } @@ -183,9 +189,9 @@ export class ApiMachineClient { const includeHidden = params?.includeHidden === true - const targetPath = await this.pathPolicy.resolveForCheck(rawPath) - if (!this.pathPolicy.isWithinBrowseRoots(targetPath)) { - return { success: false, error: 'Path is outside browse roots' } + const targetPath = await this.resolveForWorkspaceCheck(rawPath) + if (!this.isWithinWorkspaceRoots(targetPath)) { + return { success: false, error: 'Path is outside workspace roots' } } try { @@ -258,8 +264,8 @@ export class ApiMachineClient { return { success: false, error: 'cwd is required' } } - const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) - if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -267,31 +273,14 @@ export class ApiMachineClient { } ) - this.rpcHandlerManager.registerHandler<{ cwd?: string | null }, ListOpencodeModelVariantsResponse>( - RPC_METHODS.ListOpencodeModelVariants, - async (params) => { - const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' - if (!rawCwd) { - return { success: false, error: 'cwd is required' } - } - - const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) - if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { - return { success: false, error: 'Path is outside workspace roots' } - } - - return await listOpencodeModelVariants({ cwd: resolvedCwd }) - } - ) - this.rpcHandlerManager.registerHandler( RPC_METHODS.ListGrokModelsForCwd, async (params) => { const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' if (!rawCwd) return { success: false, error: 'cwd is required' } - const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) - if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -305,8 +294,8 @@ export class ApiMachineClient { const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' if (!rawCwd) return { success: false, error: 'cwd is required' } - const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) - if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -321,8 +310,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid Codex sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) - if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -354,6 +343,30 @@ export class ApiMachineClient { } ) + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ListClaudeSessions, + async (params) => { + const parsed = ListClaudeSessionsRpcRequestSchema.safeParse(params) + if (!parsed.success) return { success: false, error: 'Invalid Claude sessions request' } + const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' + if (rawCwd) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + } + const requestedIds = parsed.data.sessionIds ? new Set(parsed.data.sessionIds) : null + const allSessions = requestedIds + ? listLocalClaudeSessionsWithMessagesByIds(requestedIds) + : listLocalClaudeSessionSummaries() + const sessions = [] + for (const session of allSessions) { + if (await this.isLocalSessionWithinWorkspaceRoots(session)) sessions.push(session) + } + return { success: true, sessions } + } + ) + this.rpcHandlerManager.registerHandler( RPC_METHODS.ListPiSessions, async (params) => { @@ -361,8 +374,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid Pi sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) - if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -377,14 +390,98 @@ export class ApiMachineClient { return { success: true, sessions } } ) + + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ListDshSessions, + async (params) => { + const parsed = ListDshSessionsRpcRequestSchema.safeParse(params) + if (!parsed.success) return { success: false, error: 'Invalid DeepSeek Harness sessions request' } + const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' + if (rawCwd) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + } + try { + const result = await listDshSessions({ + cwd: rawCwd || null, + sessionIds: parsed.data.sessionIds ? new Set(parsed.data.sessionIds) : null + }) + const sessions = [] + for (const candidate of result.sessions) { + if (await this.isLocalSessionWithinWorkspaceRoots(candidate)) sessions.push(candidate) + } + return { success: true, sessions, sourceUrl: result.sourceUrl } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list DeepSeek Harness sessions' + } + } + } + ) + + this.rpcHandlerManager.registerHandler, DshModelsResponse>( + RPC_METHODS.ListDshModels, + async () => { + try { + return await listDshModels() + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list DeepSeek Harness models' + } + } + } + ) } private async isLocalSessionWithinWorkspaceRoots(session: { cwd?: string | null }): Promise { - if (!this.pathPolicy.hasWorkspaceRoots()) return true + if (!this.normalizedWorkspaceRoots?.length) return true const cwd = session.cwd?.trim() if (!cwd) return false - const resolvedCwd = await this.pathPolicy.resolveForCheck(cwd) - return this.pathPolicy.isWithinSpawnRoots(resolvedCwd) + const resolvedCwd = await this.resolveForWorkspaceCheck(cwd) + return this.isWithinWorkspaceRoots(resolvedCwd) + } + + private isWithinWorkspaceRoots(absolutePath: string): boolean { + if (!this.normalizedWorkspaceRoots?.length) return true + return this.normalizedWorkspaceRoots.some((workspaceRoot) => { + const rel = relative(workspaceRoot, absolutePath) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) + }) + } + + /** + * Canonicalize a path for workspace-root containment checks. Resolves + * symlinks via realpath so a symlink such as `/safe/out -> /etc` cannot + * be used to escape the configured root with a lexical-only check. + * + * If the path doesn't exist (e.g. a session is being spawned in a + * directory we'll create), walks up to the nearest existing ancestor + * and realpaths *that*, joining the missing tail back on. This way the + * check still runs against the real on-disk location once any + * intermediate symlink in the parent chain has been resolved. + */ + private async resolveForWorkspaceCheck(path: string): Promise { + const absolute = resolvePath(path) + try { + return normalizeWindowsDriveRoot(await realpath(absolute)) + } catch { + const missing: string[] = [] + let cursor = absolute + while (cursor !== dirname(cursor)) { + missing.unshift(basename(cursor)) + cursor = dirname(cursor) + try { + return join(normalizeWindowsDriveRoot(await realpath(cursor)), ...missing) + } catch { + // keep walking to the nearest existing parent + } + } + return normalizeWindowsDriveRoot(absolute) + } } setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { @@ -395,13 +492,9 @@ export class ApiMachineClient { throw new Error('Directory is required') } - const resolvedDirectory = await this.pathPolicy.resolveForCheck(directory) - if (!this.pathPolicy.isWithinSpawnRoots(resolvedDirectory)) { - return { - type: 'error', - errorMessage: 'Directory is outside this machine\'s workspace roots', - code: 'outside_workspace_roots', - } + const resolvedDirectory = await this.resolveForWorkspaceCheck(directory) + if (!this.isWithinWorkspaceRoots(resolvedDirectory)) { + return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' } } const result = await spawnSession({ @@ -424,8 +517,7 @@ export class ApiMachineClient { sessionType, worktreeName, startingMode, - forkSession: forkSession === true, - validateDirectory: async (path) => await this.pathPolicy.allowsSpawn(path), + forkSession: forkSession === true }) switch (result.type) { @@ -434,12 +526,7 @@ export class ApiMachineClient { case 'requestToApproveDirectoryCreation': return { type: 'requestToApproveDirectoryCreation', directory: result.directory } case 'error': - return { - type: 'error', - errorMessage: result.errorMessage, - code: result.code, - agent: result.agent, - } + return { type: 'error', errorMessage: result.errorMessage } } }) @@ -683,8 +770,6 @@ export class ApiMachineClient { shutdown(): void { this.stopKeepAlive() - // The listener holds this client, and the socket is about to close. - setAgyCatalogChangeListener(null) if (this.socket) { this.socket.close() } diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index 3792ce80..9721b487 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -290,6 +290,41 @@ describe('ApiSessionClient lazy materialization', () => { client.close() }) + it('keeps an api-error retry event when the pending droppable queue overflows', async () => { + socketHarness.sockets.length = 0 + const pendingMaterialization = deferred() + const client = new ApiSessionClient('token', createSession(), { + materialize: async () => await pendingMaterialization.promise + }) + + client.notifyUserActivity() + client.sendSessionEvent({ + type: 'api-error', + retryAttempt: 5, + maxRetries: 5, + error: { message: 'Too many requests', status: 429 }, + retryScheduled: true + }) + for (let index = 0; index < 300; index += 1) { + client.sendSessionEvent({ type: 'ready' }) + } + + pendingMaterialization.resolve(createSession({ namespace: 'default' })) + expect(await client.materialize()).toBe(true) + + const events = socketHarness.sockets[0]?.emitted + .filter((entry) => entry.event === 'message') + .map((entry) => (entry.args[0] as { message: { content: { data: { type: string } } } }).message.content.data) + expect(events).toContainEqual({ + type: 'api-error', + retryAttempt: 5, + maxRetries: 5, + error: { message: 'Too many requests', status: 429 }, + retryScheduled: true + }) + client.close() + }) + it('drains in-flight materialization and initial socket delivery before closing', async () => { socketHarness.sockets.length = 0 const pendingMaterialization = deferred() diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index aed3ba04..7cade4f6 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -1093,6 +1093,13 @@ export class ApiSessionClient extends EventEmitter { } | { type: 'error' message: string + } | { + // Structured native provider retry event (for example HTTP 429). + type: 'api-error' + retryAttempt: number + maxRetries: number + error: unknown + retryScheduled: true } | { type: 'permission-mode-changed' mode: SessionPermissionMode @@ -1125,7 +1132,12 @@ export class ApiSessionClient extends EventEmitter { sid: this.sessionId, message: content }) - }, event.type === 'message' || event.type === 'error' || event.type === 'compact-summary' ? 'lossless' : 'droppable') + }, event.type === 'message' + || event.type === 'error' + || event.type === 'api-error' + || event.type === 'compact-summary' + ? 'lossless' + : 'droppable') } emitAgentTerminalOutput(data: string): void { diff --git a/cli/src/claude/utils/transcriptMessages.ts b/cli/src/claude/utils/transcriptMessages.ts new file mode 100644 index 00000000..f5eb4017 --- /dev/null +++ b/cli/src/claude/utils/transcriptMessages.ts @@ -0,0 +1,29 @@ +import type { RawJSONLines } from '@/claude/types' + +const SYSTEM_INJECTION_PREFIXES = ['', '', '', ''] + +export function extractRawUserTextContent(content: unknown): string | null { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return null + + const parts = content + .map((block) => { + if (!block || typeof block !== 'object' || Array.isArray(block)) return null + const record = block as Record + return record.type === 'text' && typeof record.text === 'string' ? record.text : null + }) + .filter((text): text is string => text !== null) + + return parts.length > 0 ? parts.join('\n') : null +} + +export function isExternalUserMessage(body: RawJSONLines): body is Extract { + if (body.type !== 'user') return false + const message = (body as { message?: { content?: unknown } }).message + if (!message || typeof message !== 'object') return false + const text = extractRawUserTextContent(message.content) + if (text === null || body.isSidechain === true || body.isMeta === true) return false + + const trimmed = text.trimStart() + return !SYSTEM_INJECTION_PREFIXES.some((prefix) => trimmed.startsWith(prefix)) +} diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 83a71a3a..b2511727 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -95,6 +95,8 @@ const harness = vi.hoisted(() => ({ emitRunningChildTurnBeforeSuppressedParent: false, emitCompletedChildTurnBeforeSuppressedParent: false, emitTurnAbortedOnInterrupt: false, + capacityErrorsRemaining: 0, + capacityErrorRetryAfterMs: null as number | null, bridgeOptions: [] as unknown[] })); @@ -339,6 +341,25 @@ vi.mock('./codexAppServerClient', () => { return { turn: { id: turnId } }; } + if (harness.capacityErrorsRemaining > 0) { + harness.capacityErrorsRemaining -= 1; + const overloaded = { + threadId, + turnId, + error: { + message: 'Selected model is at capacity. Please try a different model.', + codexErrorInfo: 'server_overloaded' + }, + willRetry: false, + ...(harness.capacityErrorRetryAfterMs !== null + ? { retryAfterMs: harness.capacityErrorRetryAfterMs } + : {}) + }; + harness.notifications.push({ method: 'error', params: overloaded }); + this.notificationHandler?.('error', overloaded); + return { turn: { id: turnId } }; + } + if (harness.emitModelSafetyNotices) { harness.emitModelSafetyNotices = false; const rerouted = { @@ -1491,6 +1512,8 @@ describe('codexRemoteLauncher', () => { harness.emitRunningChildTurnBeforeSuppressedParent = false; harness.emitCompletedChildTurnBeforeSuppressedParent = false; harness.emitTurnAbortedOnInterrupt = false; + harness.capacityErrorsRemaining = 0; + harness.capacityErrorRetryAfterMs = null; harness.bridgeOptions = []; }); @@ -2230,7 +2253,7 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); - it('does not retry an explicitly non-retryable error even when its text is retryable', async () => { + it('does not retry an explicitly non-retryable fatal error', async () => { harness.suppressTurnCompletion = true; const { session, sessionEvents } = createSessionStub(['first message']); @@ -2242,7 +2265,7 @@ describe('codexRemoteLauncher', () => { harness.dispatchNotification?.('error', { threadId: 'thread-1', turnId: 'turn-1', - error: { message: 'Selected model is at capacity' }, + error: { message: 'permission denied' }, willRetry: false }); @@ -2250,12 +2273,101 @@ describe('codexRemoteLauncher', () => { expect(harness.startTurnMessages).toEqual(['first message']); expect(sessionEvents).toContainEqual({ type: 'message', - message: 'Task failed: Selected model is at capacity' + message: 'Task failed: permission denied' }); expect(sessionEvents.some((event) => String(event.message ?? '').includes('retrying same conversation'))).toBe(false); expect(session.thinking).toBe(false); }); + it('retries a server-overloaded model on the same thread after the advertised delay', async () => { + harness.capacityErrorsRemaining = 1; + harness.capacityErrorRetryAfterMs = 0; + const { session, sessionEvents } = createSessionStub(['first message']); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startThreadIds).toEqual(['thread-1']); + expect(harness.startTurnMessages).toEqual(['first message', 'first message']); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Task failed: Selected model is at capacity. Please try a different model.; retrying same conversation (1/6)' + }); + expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); + expect(session.thinking).toBe(false); + }); + + it('keeps retrying a capacity failure past the old three-attempt budget', async () => { + // Four consecutive capacity failures exceed the previous limit of 3. + // The advertised retryAfterMs keeps the test fast while still + // exercising the scheduled-retry path. + harness.capacityErrorsRemaining = 4; + harness.capacityErrorRetryAfterMs = 50; + const { session, sessionEvents } = createSessionStub(['first message']); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnMessages).toEqual(Array(5).fill('first message')); + const retryMessages = sessionEvents + .filter((event) => event.type === 'message' && String(event.message).includes('retrying same conversation')) + .map((event) => event.message); + expect(retryMessages).toEqual([ + 'Task failed: Selected model is at capacity. Please try a different model.; retrying same conversation in 1 second (1/6)', + 'Task failed: Selected model is at capacity. Please try a different model.; retrying same conversation in 1 second (2/6)', + 'Task failed: Selected model is at capacity. Please try a different model.; retrying same conversation in 1 second (3/6)', + 'Task failed: Selected model is at capacity. Please try a different model.; retrying same conversation in 1 second (4/6)' + ]); + expect(sessionEvents.filter((event) => event.type === 'message' + && String(event.message).startsWith('Task failed: Selected model is at capacity') + && !String(event.message).includes('retrying same conversation'))).toEqual([]); + expect(session.thinking).toBe(false); + }); + + it('holds the thinking state across a capacity backoff window', async () => { + // While a retry waits out its 7s backoff the turn must not look + // finished: previously thinking toggled off for the whole window, so + // the conversation appeared to have failed until the retry re-armed it. + harness.capacityErrorsRemaining = 1; + harness.capacityErrorRetryAfterMs = null; + const { session, thinkingChanges } = createSessionStub(['first message']); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnMessages).toEqual(['first message', 'first message']); + expect(session.sessionId).toBe('thread-1'); + // The failure that arms the retry must not report the turn as + // finished: only the successful retried turn may clear thinking. + expect(thinkingChanges[0]).toBe(true); + const firstOffIndex = thinkingChanges.indexOf(false); + const lastOnIndex = thinkingChanges.lastIndexOf(true); + expect(firstOffIndex).toBeGreaterThan(lastOnIndex); + expect(session.thinking).toBe(false); + }, 20_000); + + it('stops retrying a capacity failure once the extended budget is exhausted', async () => { + // More failures than the budget allows: the last one must surface as a + // terminal failure instead of scheduling another retry. + harness.capacityErrorsRemaining = 8; + harness.capacityErrorRetryAfterMs = 0; + const { session, sessionEvents } = createSessionStub(['first message']); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnMessages).toHaveLength(7); + const retryMessages = sessionEvents + .filter((event) => event.type === 'message' && String(event.message).includes('retrying same conversation')); + expect(retryMessages).toHaveLength(6); + expect(retryMessages.at(-1)?.message).toContain('(6/6)'); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Task failed: Selected model is at capacity. Please try a different model.' + }); + expect(session.thinking).toBe(false); + }); + it('retries a safety-buffered turn with the offered faster model only after user opt-in', async () => { harness.emitSafetyBuffering = true; harness.safetyBufferingFasterModel = 'gpt-5.4-mini'; diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index f2290f38..fac12854 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -100,11 +100,22 @@ const SAME_THREAD_RETRYABLE_ERROR_PATTERNS = [ 'selected model is at capacity', 'codex thread entered systemerror' ]; +const SAME_THREAD_SERVER_OVERLOADED_ERROR_INFO = 'serveroverloaded'; +// Capacity windows are upstream-wide and routinely outlast a single 52s +// (7+15+30) budget: the Codex app itself keeps retrying for minutes, so a +// short budget made hapi surface "Task failed: ... at capacity" while the +// model was about to come back. Growing the exponent to ~9 minutes total +// keeps hapi's retries on the same timescale as the client it wraps. +const SAME_THREAD_CAPACITY_RETRY_DELAYS_MS = [7_000, 15_000, 30_000, 60_000, 120_000, 300_000] as const; const CONTEXT_COMPACT_RETRYABLE_ERROR_PATTERNS = [ 'ran out of room in the model', 'context window', 'clear earlier history' ]; +// Capacity/overload failures back off for minutes (see the table above), so +// they get a budget sized to the table. Thread-systemerror recovery re-enters +// the turn synchronously with no delay, so it keeps the original tight budget. +const SAME_THREAD_MAX_CAPACITY_RETRIES = SAME_THREAD_CAPACITY_RETRY_DELAYS_MS.length; const SAME_THREAD_MAX_RETRIES = 3; const SAME_THREAD_MAX_COMPACT_RETRIES = 1; const SAME_THREAD_COMPACT_TIMEOUT_MS = 10 * 60 * 1000; @@ -158,6 +169,34 @@ function isSameThreadRetryableCodexError(error: string | null): boolean { return SAME_THREAD_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern)); } +function isServerOverloadedCodexError(msg: Record, error: string | null): boolean { + const errorInfo = normalizePolicyToken(msg.codex_error_info ?? msg.codexErrorInfo); + return errorInfo === SAME_THREAD_SERVER_OVERLOADED_ERROR_INFO + || error?.toLowerCase().includes('selected model is at capacity') === true; +} + +function retryDelayMsForCodexFailure(msg: Record, attempt: number): number { + const explicitMilliseconds = msg.retry_after_ms ?? msg.retryAfterMs; + if (typeof explicitMilliseconds === 'number' && Number.isFinite(explicitMilliseconds)) { + return Math.max(0, Math.min(5 * 60 * 1000, explicitMilliseconds)); + } + + const explicitSeconds = msg.retry_after_seconds ?? msg.retryAfterSeconds; + if (typeof explicitSeconds === 'number' && Number.isFinite(explicitSeconds)) { + return Math.max(0, Math.min(5 * 60, explicitSeconds)) * 1000; + } + + return SAME_THREAD_CAPACITY_RETRY_DELAYS_MS[Math.min(Math.max(attempt - 1, 0), SAME_THREAD_CAPACITY_RETRY_DELAYS_MS.length - 1)] ?? 0; +} + +function formatRetryDelay(delayMs: number): string { + if (delayMs <= 0) { + return ''; + } + const seconds = Math.max(1, Math.ceil(delayMs / 1000)); + return ` in ${seconds} second${seconds === 1 ? '' : 's'}`; +} + function normalizePolicyToken(value: unknown): string { return typeof value === 'string' ? value.toLowerCase().replace(/[^a-z0-9]/g, '') @@ -1863,9 +1902,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }; let activeMessage: QueuedMessage | null = null; + let lastRetryableMessage: QueuedMessage | null = null; let sameThreadRetryAttempt = 0; let sameThreadCompactAttempt = 0; let recoveryInFlight = false; + let sameThreadRetryTimer: ReturnType | null = null; + let sameThreadRetryGeneration = 0; + // True while a retry is parked waiting for its backoff to elapse. The + // terminal event that armed the retry must not clear the session's + // thinking state, or the conversation looks finished for the whole + // backoff window (minutes, at the long end of the capacity table). + let pendingSameThreadRetry = false; let lastFinalizedTurnId: string | null = null; let deferredThreadStatusFailure: { event: Record; @@ -2262,6 +2309,72 @@ class CodexRemoteLauncher extends RemoteLauncherBase { wakeLoop(); }; + const clearSameThreadRetry = () => { + if (sameThreadRetryTimer) { + clearTimeout(sameThreadRetryTimer); + sameThreadRetryTimer = null; + } + sameThreadRetryGeneration += 1; + pendingSameThreadRetry = false; + if (recoveryInFlight && !compactRecovery && !deferredThreadStatusFailure) { + recoveryInFlight = false; + wakeLoop(); + } + }; + + session.client.rpcHandlerManager.registerHandler( + RPC_METHODS.RetryCodexTurn, + async () => { + const messageToRetry = activeMessage ?? lastRetryableMessage; + if (!messageToRetry || !this.currentThreadId) { + return { retried: false, error: 'No retryable Codex turn is available' }; + } + clearSameThreadRetry(); + sameThreadRetryAttempt = 0; + sameThreadCompactAttempt = 0; + lastRetryableMessage = null; + pending = messageToRetry; + recoveryInFlight = false; + wakeLoop(); + return { retried: true }; + } + ); + + const scheduleSameThreadRetry = (messageToRetry: QueuedMessage, delayMs: number) => { + if (sameThreadRetryTimer) { + clearTimeout(sameThreadRetryTimer); + } + const generation = ++sameThreadRetryGeneration; + const signal = this.abortController.signal; + recoveryInFlight = true; + const runRetry = () => { + if (generation !== sameThreadRetryGeneration) { + return; + } + sameThreadRetryTimer = null; + pendingSameThreadRetry = false; + if (signal.aborted || this.shouldExit) { + recoveryInFlight = false; + pending = null; + activeMessage = null; + wakeLoop(); + return; + } + pending = messageToRetry; + recoveryInFlight = false; + wakeLoop(); + }; + + if (delayMs <= 0) { + runRetry(); + return; + } + + pendingSameThreadRetry = true; + sameThreadRetryTimer = setTimeout(runRetry, delayMs); + sameThreadRetryTimer.unref?.(); + }; + const failCompactRecovery = (recovery: typeof compactRecovery, message: string) => { if (!recovery || compactRecovery !== recovery) { return; @@ -2909,7 +3022,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const isThreadStatusFailure = msgType === 'task_failed' && msg.terminal_source === 'thread_status'; const error = msgType === 'task_failed' ? asString(msg.error) : null; + const isServerOverloadedFailure = msgType === 'task_failed' + && isServerOverloadedCodexError(msg, error); const explicitlyNonRetryable = msgType === 'task_failed' + && !isServerOverloadedFailure && (msg.retryable === false || isPolicyBlockedCodexFailure(msg, error)); if (deferredThreadStatusFailure && isTerminalEvent && !isThreadStatusFailure) { @@ -2983,13 +3099,27 @@ class CodexRemoteLauncher extends RemoteLauncherBase { && Boolean(activeMessage) && Boolean(this.currentThreadId) && sameThreadCompactAttempt < SAME_THREAD_MAX_COMPACT_RETRIES; + const isCapacityRetryableFailure = isServerOverloadedFailure + || isSameThreadRetryableCodexError(error); + // Only capacity/overload failures earn the long budget; a + // thread-level systemError keeps its original tight retry budget. + const sameThreadRetryLimit = isServerOverloadedFailure + ? SAME_THREAD_MAX_CAPACITY_RETRIES + : SAME_THREAD_MAX_RETRIES; const shouldRetrySameThread = msgType === 'task_failed' && !explicitlyNonRetryable && !shouldCompactAndRetrySameThread - && isSameThreadRetryableCodexError(error) + && isCapacityRetryableFailure && Boolean(activeMessage) && Boolean(this.currentThreadId) - && sameThreadRetryAttempt < SAME_THREAD_MAX_RETRIES; + && sameThreadRetryAttempt < sameThreadRetryLimit; + + // Only capacity/overload failures back off. A thread-level + // systemError is recovered by re-entering the turn immediately, so + // it must keep its original zero-delay retry. + const retryDelayMs = shouldRetrySameThread && isServerOverloadedFailure + ? retryDelayMsForCodexFailure(msg, sameThreadRetryAttempt + 1) + : 0; const suppressReadyForThisTerminalEvent = isTerminalEvent ? consumeInterruptedTurnReadySuppression(eventTurnId) @@ -3036,10 +3166,14 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } else if (shouldRetrySameThread) { sameThreadRetryAttempt += 1; - pending = activeMessage; + const messageToRetry = activeMessage; + if (messageToRetry) { + scheduleSameThreadRetry(messageToRetry, retryDelayMs); + } logger.debug( `[Codex] Retrying retryable failure on same thread ` + - `(attempt ${sameThreadRetryAttempt}/${SAME_THREAD_MAX_RETRIES}): ${error ?? 'unknown error'}` + `(attempt ${sameThreadRetryAttempt}/${sameThreadRetryLimit}, ` + + `delay=${retryDelayMs}ms): ${error ?? 'unknown error'}` ); } this.currentTurnId = null; @@ -3085,11 +3219,14 @@ class CodexRemoteLauncher extends RemoteLauncherBase { session.sendSessionEvent({ type: 'message', message: retryMessage }); } else if (shouldRetrySameThread) { const retryMessage = error - ? `Task failed: ${error}; retrying same conversation (${sameThreadRetryAttempt}/${SAME_THREAD_MAX_RETRIES})` - : `Task failed; retrying same conversation (${sameThreadRetryAttempt}/${SAME_THREAD_MAX_RETRIES})`; + ? `Task failed: ${error}; retrying same conversation${formatRetryDelay(retryDelayMs)} (${sameThreadRetryAttempt}/${sameThreadRetryLimit})` + : `Task failed; retrying same conversation${formatRetryDelay(retryDelayMs)} (${sameThreadRetryAttempt}/${sameThreadRetryLimit})`; messageBuffer.addMessage(retryMessage, 'status'); session.sendSessionEvent({ type: 'message', message: retryMessage }); } else { + if (isServerOverloadedFailure && activeMessage) { + lastRetryableMessage = activeMessage; + } const visibleError = error && isPolicyBlockedCodexFailure(msg, error) ? `${error}\n\nTrusted Access: ${CYBER_POLICY_TRUSTED_ACCESS_URL}\nLearn more: ${SAFETY_BUFFERING_LEARN_MORE_URL}` : error; @@ -3114,9 +3251,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase { setTurnInFlight(false); this.conversationHistory.setBusy(false); allowAnonymousTerminalEvent = false; - if (session.thinking) { + if (session.thinking && !pendingSameThreadRetry) { logger.debug('thinking completed'); session.onThinkingChange(false); + } else if (session.thinking) { + logger.debug('thinking held for same-thread retry'); } diffProcessor.reset(); appServerEventConverter.reset(); @@ -3145,8 +3284,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { sameThreadRetryAttempt = 0; sameThreadCompactAttempt = 0; recoveryInFlight = false; + pendingSameThreadRetry = false; clearCompactRecovery(compactRecovery); activeMessage = null; + lastRetryableMessage = null; } if (msgType === 'agent_reasoning_section_break') { @@ -3737,6 +3878,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resetCurrentTurnState = () => { clearDeferredThreadStatusFailure(); + clearSameThreadRetry(); cancelSafetyBufferingRequest('Session reset'); setTurnInFlight(false); allowAnonymousTerminalEvent = false; @@ -4054,6 +4196,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { await runSteerReconciliation(); } if (!pending && recoveryInFlight) { + // A same-thread retry parked in its backoff sets + // recoveryInFlight, so the loop waits here rather than tearing + // down the turn context (and the session's thinking state) + // while the retry is still on its way. await waitForTurnOrRecovery(this.abortController.signal); if (this.abortController.signal.aborted && !this.shouldExit) { logger.debug('[codex]: Internal wait aborted while recovery was active; continuing'); @@ -4097,6 +4243,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (!isRetryMessage) { messageBuffer.addMessage(message.message, 'user'); + lastRetryableMessage = null; } activeMessage = message; const isGoalCommand = parseGoalCommand(message.message) !== null; @@ -4308,7 +4455,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { pendingAgentTracesByAgentId.clear(); cancelAllPendingThrottledAgentRunUpdates(); childAgentRuntimeById.clear(); - session.onThinkingChange(false); + if (!pendingSameThreadRetry) { + session.onThinkingChange(false); + } clearReadyAfterTurnTimer?.(); if (!suppressReadyAfterMessage) { emitReadyIfIdle({ @@ -4328,6 +4477,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { failPendingAgentStarts('spawn_agent did not return an agent id before the Codex session ended'); clearDeferredThreadStatusFailure(); + clearSameThreadRetry(); cancelSafetyBufferingRequest('Session ended'); cancelAllPendingThrottledAgentRunUpdates(); // Stop reconciliation: the launcher is leaving; no pending steer may diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 00e18eb7..68a9d3e7 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -719,6 +719,33 @@ describe('AppServerEventConverter', () => { }]); }); + it('normalizes wrapped task_complete errors into retryable task failures', () => { + const converter = new AppServerEventConverter(); + + const completed = converter.handleNotification('codex/event/task_complete', { + msg: { + type: 'task_complete', + thread_id: 'thread-1', + turn_id: 'turn-1', + error: { + message: 'Selected model is at capacity. Please try a different model.', + codex_error_info: 'server_overloaded' + }, + retry_after_ms: 0 + } + }); + + expect(completed).toEqual([{ + type: 'task_failed', + thread_id: 'thread-1', + turn_id: 'turn-1', + error: 'Selected model is at capacity. Please try a different model.', + codex_error_info: 'server_overloaded', + retry_after_ms: 0, + terminal_source: 'wrapped_task_complete' + }]); + }); + it('ignores wrapped terminal lifecycle events without turn_id', () => { const converter = new AppServerEventConverter(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 1421b826..8860bc53 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -543,7 +543,21 @@ export class AppServerEventConverter { return []; } - const event: ConvertedEvent = { ...msgScope, type: msgType }; + const errorRecord = asRecord(msg.error); + const error = asString(msg.error) ?? asString(msg.message) ?? asString(errorRecord?.message); + const codexErrorInfo = extractCodexErrorInfo(msg, errorRecord); + const retryable = asBoolean(msg.will_retry ?? msg.willRetry ?? errorRecord?.will_retry ?? errorRecord?.willRetry); + const retryAfterMs = asNumber(msg.retry_after_ms ?? msg.retryAfterMs ?? errorRecord?.retry_after_ms ?? errorRecord?.retryAfterMs); + // Older Codex runtimes report an overloaded turn as task_complete + // with an embedded error object. Treat that as a failure so the + // launcher can apply the same retry policy as canonical failures. + const hasEmbeddedError = msg.error !== undefined + || msg.codexErrorInfo !== undefined + || msg.codex_error_info !== undefined; + const normalizedMsgType = msgType === 'task_complete' && hasEmbeddedError && error + ? 'task_failed' + : msgType; + const event: ConvertedEvent = { ...msgScope, type: normalizedMsgType }; if (turnId) { event.turn_id = turnId; } @@ -551,11 +565,22 @@ export class AppServerEventConverter { if (threadId) { event.thread_id = threadId; } - if (msgType === 'task_failed') { - const error = asString(msg.error ?? msg.message ?? asRecord(msg.error)?.message); + if (normalizedMsgType === 'task_failed' && msgType === 'task_complete') { + event.terminal_source = 'wrapped_task_complete'; + } + if (normalizedMsgType === 'task_failed') { if (error) { event.error = error; } + if (codexErrorInfo) { + event.codex_error_info = codexErrorInfo; + } + if (retryable !== null) { + event.retryable = retryable; + } + if (retryAfterMs !== null) { + event.retry_after_ms = retryAfterMs; + } } return [event]; } @@ -759,13 +784,20 @@ export class AppServerEventConverter { return events; } - if (status === 'failed' || status === 'error') { + if (status === 'failed' || status === 'error' || errorMessage) { const codexErrorInfo = extractCodexErrorInfo(paramsRecord, turnError); + const retryAfterMs = asNumber( + paramsRecord.retry_after_ms + ?? paramsRecord.retryAfterMs + ?? turnError?.retry_after_ms + ?? turnError?.retryAfterMs + ); events.push(scoped({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), terminal_source: 'turn_completed', ...(codexErrorInfo ? { codex_error_info: codexErrorInfo } : {}), + ...(retryAfterMs !== null ? { retry_after_ms: retryAfterMs } : {}), ...(errorMessage ? { error: errorMessage } : {}) })); return events; @@ -842,11 +874,18 @@ export class AppServerEventConverter { const message = asString(paramsRecord.message) ?? asString(errorRecord?.message); if (message) { const codexErrorInfo = extractCodexErrorInfo(paramsRecord, errorRecord); + const retryAfterMs = asNumber( + paramsRecord.retry_after_ms + ?? paramsRecord.retryAfterMs + ?? errorRecord?.retry_after_ms + ?? errorRecord?.retryAfterMs + ); events.push(scoped({ type: 'task_failed', terminal_source: 'error', ...(retryable !== null ? { retryable } : {}), ...(codexErrorInfo ? { codex_error_info: codexErrorInfo } : {}), + ...(retryAfterMs !== null ? { retry_after_ms: retryAfterMs } : {}), error: message })); } diff --git a/cli/src/commands/dsh.ts b/cli/src/commands/dsh.ts index 33e9eb08..0b89fb43 100644 --- a/cli/src/commands/dsh.ts +++ b/cli/src/commands/dsh.ts @@ -1,30 +1,17 @@ import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' import { initializeToken } from '@/ui/tokenInit' import { maybeAutoStartServer } from '@/utils/autoStartServer' -import { authAndSetupMachineIfNeeded } from '@/ui/auth' -import { parseRemoteAgentCommandOptions } from './agentCommandOptions' +import { DSH_PERMISSION_MODES } from '@hapi/protocol' import type { CommandDefinition } from './types' - -export function parseDshCommandOptions(commandArgs: string[]) { - const options = parseRemoteAgentCommandOptions(commandArgs, [], ['remote']) - if (commandArgs.includes('--yolo')) { - throw new Error('DeepSeek Harness permission policy is configured by the ACP server') - } - if (options.resumeSessionId) { - throw new Error('DeepSeek Harness ACP only supports fresh sessions; resume is unavailable') - } - if (options.model || options.effort || options.modelReasoningEffort) { - throw new Error('DeepSeek Harness model and effort are configured by the ACP server') - } - return { ...options, startingMode: 'remote' as const } -} +import { parseRemoteAgentCommandOptions } from './agentCommandOptions' export const dshCommand: CommandDefinition = { name: 'dsh', - requiresRuntimeAssets: true, + requiresRuntimeAssets: false, run: async ({ commandArgs }) => { try { - const options = parseDshCommandOptions(commandArgs) + const options = parseRemoteAgentCommandOptions(commandArgs, DSH_PERMISSION_MODES) await initializeToken() await maybeAutoStartServer() await authAndSetupMachineIfNeeded() diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 2bf8c598..e1913b6f 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -5,6 +5,7 @@ import { claudeCommand } from './claude' import { codexCommand } from './codex' import { dshCommand } from './dsh' import { cursorCommand } from './cursor' +import { dshCommand } from './dsh' import { connectCommand } from './connect' import { runnerCommand } from './runner' import { resumeCommand } from './resume' @@ -46,6 +47,7 @@ const COMMANDS: CommandDefinition[] = [ codexCommand, dshCommand, cursorCommand, + dshCommand, removedGeminiCommand, grokCommand, kimiCommand, diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index 4582d0f4..70657936 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import React from 'react' import { render } from 'ink' import { existsSync } from 'node:fs' -import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol' +import type { DshPermissionMode, LocalResumeTarget, ResumableSession } from '@hapi/protocol' import type { AgyPermissionMode, ClaudePermissionMode, @@ -107,6 +107,21 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.') } + if (target.flavor === 'dsh') { + const { runDsh } = await import('@/dsh/runDsh') + await runDsh({ + existingSessionId: base.existingSessionId, + workingDirectory: base.workingDirectory, + resumeSessionId: base.resumeSessionId, + startedBy: base.startedBy, + permissionMode: base.permissionMode as DshPermissionMode | undefined, + startingMode: 'remote', + model: target.model ?? undefined, + modelReasoningEffort: target.modelReasoningEffort ?? undefined + }) + return + } + if (target.flavor === 'opencode') { const { runOpencode } = await import('@/opencode/runOpencode') await runOpencode({ diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index 89eca09f..a273423f 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -13,7 +13,6 @@ import { import type { CursorSession } from './session'; import type { EnhancedMode } from './loop'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; -import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; // TODO(cursor-acp): remove legacy stream-json resume path after migration window. // New Cursor sessions use ACP only. This path exists because pre-ACP Cursor // session_id values are not loadable via ACP session/load. @@ -267,7 +266,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { onEvent: (event: ReturnType & object) => void ): Promise<{ exitCode: number | null; stderr: string }> { return new Promise((resolve, reject) => { - const child = spawn(getAgentLaunchCommand('cursor'), args, { + const child = spawn('agent', args, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/cli/src/dsh/dshEvents.test.ts b/cli/src/dsh/dshEvents.test.ts new file mode 100644 index 00000000..1f009536 --- /dev/null +++ b/cli/src/dsh/dshEvents.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { convertDshEvent, convertDshHistoryEntry } from './dshEvents' +import type { DshSessionEvent } from './dshWebClient' + +function event(type: string, data: unknown, seq = 1): DshSessionEvent { + return { type, data, seq, time: 1_786_000_000_000 + seq } +} + +describe('DeepSeek Harness event conversion', () => { + it('imports only human-authored user messages', () => { + const human = convertDshEvent(event('user/message', { + source: { kind: 'user', rpcId: 'rpc-1' }, + content: [{ type: 'text', text: 'hello' }] + })) + const internal = convertDshEvent(event('user/message', { + source: { kind: 'reminder' }, + content: [{ type: 'text', text: 'internal context' }] + })) + + expect(human.humanText).toBe('hello') + expect(internal).toEqual({ messages: [] }) + }) + + it('preserves reasoning, text, model, and cache token accounting', () => { + const converted = convertDshEvent(event('assistant/message', { + message: { + id: 'assistant-1', + source: { kind: 'model', model: 'deepseek-v4-pro' }, + content: [ + { type: 'reasoning', text: 'think' }, + { type: 'text', text: 'answer' } + ] + }, + usage: { + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 900, + cacheWriteTokens: 5, + reasoningTokens: 12 + } + })) + + expect(converted.model).toBe('deepseek-v4-pro') + expect(converted.messages).toEqual([ + { type: 'reasoning', text: 'think', id: 'assistant-1:reasoning' }, + { type: 'text', text: 'answer', id: 'assistant-1:text' }, + { + type: 'usage', + inputTokens: 100, + outputTokens: 20, + thoughtTokens: 12, + cacheReadTokens: 900, + cacheCreationTokens: 5 + } + ]) + }) + + it('builds stable local ids from native event sequence numbers', () => { + const entry = { + event: event('user/message', { + source: { kind: 'user' }, + content: [{ type: 'text', text: 'hello' }] + }, 42) + } + expect(convertDshHistoryEntry('session-1', entry)[0]).toMatchObject({ + localId: 'dsh:session-1:42:user', + eventSeq: 42, + content: { role: 'user' } + }) + }) + + it('surfaces native retry failures as api-error events', () => { + const converted = convertDshEvent(event('llm/retry', { + retryId: 'retry-1', + retry: 2, + maxRetries: 3, + failure: { code: 'RATE_LIMIT', status: 429, message: 'Too many requests' } + })) + + expect(converted).toEqual({ + messages: [], + events: [{ + type: 'api-error', + retryAttempt: 2, + maxRetries: 3, + error: { + code: 'RATE_LIMIT', + status: 429, + message: 'Too many requests (RATE_LIMIT, HTTP 429)' + }, + retryScheduled: true + }] + }) + }) + + it('surfaces terminal turn errors instead of silently ending the turn', () => { + const converted = convertDshEvent(event('turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'RATE_LIMIT', status: 429, message: 'Rate limited' } } + })) + + expect(converted.messages).toEqual([{ + type: 'error', + message: 'Rate limited (RATE_LIMIT, HTTP 429)' + }]) + }) + + it('imports retry events into the persisted event stream', () => { + const imported = convertDshHistoryEntry('session-1', { + event: event('llm/retry', { + retry: 1, + maxRetries: 2, + failure: { code: 'RATE_LIMIT', message: 'Too many requests' } + }, 9) + }) + + expect(imported).toMatchObject([{ + localId: 'dsh:session-1:9:event:0', + eventSeq: 9, + content: { + role: 'agent', + content: { + type: 'event', + data: { type: 'api-error', retryAttempt: 1, maxRetries: 2, retryScheduled: true } + } + } + }]) + }) +}) diff --git a/cli/src/dsh/dshEvents.ts b/cli/src/dsh/dshEvents.ts new file mode 100644 index 00000000..9d973e4d --- /dev/null +++ b/cli/src/dsh/dshEvents.ts @@ -0,0 +1,247 @@ +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import type { DshImportedMessage } from '@hapi/protocol/apiTypes' +import { convertAgentMessage } from '@/agent/messageConverter' +import type { AgentMessage } from '@/agent/types' +import type { DshHistoryEntry, DshSessionEvent } from './dshWebClient' + +type JsonRecord = Record + +type DshEventMessage = { + type: 'api-error' + retryAttempt: number + maxRetries: number + error: unknown + retryScheduled: true +} | { + type: 'error' + message: string +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function formatDshError(error: JsonRecord | null, fallback: string): string { + const message = asString(error?.message) ?? fallback + const code = asString(error?.code) + const status = asNumber(error?.status) + const details = [ + code && !message.includes(code) ? code : null, + status !== null && !message.includes(String(status)) ? `HTTP ${status}` : null + ].filter((value): value is string => value !== null) + return details.length > 0 ? `${message} (${details.join(', ')})` : message +} + +function extractTextBlocks(value: unknown, type = 'text'): string { + if (!Array.isArray(value)) return '' + return value.flatMap((entry) => { + if (!isRecord(entry) || entry.type !== type || typeof entry.text !== 'string') return [] + return [entry.text] + }).join('\n') +} + +function parseArguments(value: unknown): unknown { + if (typeof value !== 'string') return value ?? {} + try { + return JSON.parse(value) as unknown + } catch { + return { value } + } +} + +function toolResultOutput(data: JsonRecord): unknown { + const message = isRecord(data.message) ? data.message : null + if (!message || !Array.isArray(message.content)) return data.meta ?? data + const resultBlock = message.content.find((entry) => isRecord(entry) && entry.type === 'tool-result') + if (!isRecord(resultBlock)) return message.content + const text = extractTextBlocks(resultBlock.content) + return text || resultBlock.content +} + +function toolView(entry: DshHistoryEntry | undefined): { title?: string; kind?: string } { + const wrapper = isRecord(entry?.view) ? entry.view : null + const view = wrapper && isRecord(wrapper.view) ? wrapper.view : null + return { + ...(asString(view?.title) ? { title: view!.title as string } : {}), + ...(asString(view?.kind) ? { kind: view!.kind as string } : {}) + } +} + +export function convertDshEvent( + event: DshSessionEvent, + entry?: DshHistoryEntry +): { messages: AgentMessage[]; events?: DshEventMessage[]; model?: string; reasoningEffort?: string; humanText?: string } { + const data = isRecord(event.data) ? event.data : null + if (!data) return { messages: [] } + + if (event.type === 'user/message') { + const source = isRecord(data.source) ? data.source : null + if (source?.kind !== 'user') return { messages: [] } + const text = extractTextBlocks(data.content) + return text ? { messages: [], humanText: text } : { messages: [] } + } + + if (event.type === 'assistant/message') { + const message = isRecord(data.message) ? data.message : null + const source = message && isRecord(message.source) ? message.source : null + const model = asString(source?.model) ?? undefined + const messageId = asString(message?.id) ?? `dsh-assistant-${event.seq}` + const content = Array.isArray(message?.content) ? message.content : [] + const messages: AgentMessage[] = [] + const reasoning = extractTextBlocks(content, 'reasoning') + const text = extractTextBlocks(content) + if (reasoning) messages.push({ type: 'reasoning', text: reasoning, id: `${messageId}:reasoning` }) + if (text) messages.push({ type: 'text', text, id: `${messageId}:text` }) + + const usage = isRecord(data.usage) ? data.usage : null + const uncachedInput = asNumber(usage?.inputTokens) + const output = asNumber(usage?.outputTokens) + if (uncachedInput !== null && output !== null) { + messages.push({ + type: 'usage', + inputTokens: uncachedInput, + outputTokens: output, + ...(asNumber(usage?.reasoningTokens) !== null ? { thoughtTokens: usage!.reasoningTokens as number } : {}), + ...(asNumber(usage?.cacheReadTokens) !== null ? { cacheReadTokens: usage!.cacheReadTokens as number } : {}), + ...(asNumber(usage?.cacheWriteTokens) !== null ? { cacheCreationTokens: usage!.cacheWriteTokens as number } : {}) + }) + } + return { messages, ...(model ? { model } : {}) } + } + + if (event.type === 'tool/call') { + const callId = asString(data.callId) + const name = asString(data.name) + if (!callId || !name) return { messages: [] } + const view = toolView(entry) + return { + messages: [{ + type: 'tool_call', + id: callId, + name, + input: parseArguments(data.arguments), + status: 'in_progress', + ...view + }] + } + } + + if (event.type === 'tool/result') { + const message = isRecord(data.message) ? data.message : null + const source = message && isRecord(message.source) ? message.source : null + const callId = asString(source?.callId) + ?? (Array.isArray(message?.content) + ? message.content.map((block) => isRecord(block) ? asString(block.toolCallId) : null).find(Boolean) ?? null + : null) + if (!callId) return { messages: [] } + const resultBlock = Array.isArray(message?.content) + ? message.content.find((block) => isRecord(block) && block.type === 'tool-result') + : null + const isError = isRecord(resultBlock) && resultBlock.isError === true + return { + messages: [{ + type: 'tool_result', + id: callId, + output: toolResultOutput(data), + status: isError ? 'failed' : 'completed' + }] + } + } + + if (event.type === 'request/header') { + const header = isRecord(data.header) ? data.header : null + const config = header && isRecord(header.config) ? header.config : null + const model = asString(config?.model) ?? undefined + const reasoningEffort = asString(config?.reasoningEffort) ?? undefined + return { + messages: [], + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}) + } + } + + if (event.type === 'llm/retry') { + const retry = asNumber(data.retry) ?? 0 + const maxRetries = asNumber(data.maxRetries) ?? 0 + const rawFailure = isRecord(data.failure) ? data.failure : null + const failure = rawFailure + ? { ...rawFailure, message: formatDshError(rawFailure, 'DeepSeek Harness API request failed') } + : data.failure ?? data + return { + messages: [], + events: [{ + type: 'api-error', + retryAttempt: retry, + maxRetries, + error: failure, + retryScheduled: true + }] + } + } + + if (event.type === 'turn/end') { + const reason = isRecord(data.reason) ? data.reason : null + if (reason?.kind === 'error') { + const error = isRecord(reason.error) ? reason.error : null + const message = formatDshError(error, 'DeepSeek Harness turn failed') + return { messages: [{ type: 'error', message }] } + } + } + + return { messages: [] } +} + +export function convertDshHistoryEntry( + sessionId: string, + entry: DshHistoryEntry +): DshImportedMessage[] { + const converted = convertDshEvent(entry.event, entry) + const result: DshImportedMessage[] = [] + if (converted.humanText) { + result.push({ + localId: `dsh:${sessionId}:${entry.event.seq}:user`, + eventSeq: entry.event.seq, + createdAt: entry.event.time, + content: { + role: 'user', + content: { type: 'text', text: converted.humanText }, + meta: { sentFrom: 'cli' } + } + }) + } + converted.messages.forEach((message, index) => { + const body = convertAgentMessage(message, converted.model) + if (!body) return + result.push({ + localId: `dsh:${sessionId}:${entry.event.seq}:agent:${index}`, + eventSeq: entry.event.seq, + createdAt: entry.event.time, + content: { + role: 'agent', + content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: body }, + meta: { sentFrom: 'cli' } + } + }) + }) + converted.events?.forEach((event, index) => { + result.push({ + localId: `dsh:${sessionId}:${entry.event.seq}:event:${index}`, + eventSeq: entry.event.seq, + createdAt: entry.event.time, + content: { + role: 'agent', + content: { type: 'event', data: event }, + meta: { sentFrom: 'cli' } + } + }) + }) + return result +} diff --git a/cli/src/dsh/dshModels.ts b/cli/src/dsh/dshModels.ts new file mode 100644 index 00000000..12f73bf0 --- /dev/null +++ b/cli/src/dsh/dshModels.ts @@ -0,0 +1,53 @@ +import type { DshModelsResponse } from '@hapi/protocol/apiTypes' +import { DshWebClient } from './dshWebClient' + +function toResponse(catalog: Awaited>): DshModelsResponse { + return { + success: true, + current: { + provider: catalog.current.provider, + modelId: catalog.current.model, + ...(catalog.current.reasoningEffort + ? { reasoningEffort: catalog.current.reasoningEffort } + : {}) + }, + availableModels: catalog.models.map((model) => ({ + provider: model.provider, + providerName: model.providerName, + modelId: model.model, + name: model.name, + reasoningEfforts: model.reasoningEfforts + })) + } +} + +export async function getDshModelsForSession( + client: DshWebClient, + sessionId: string +): Promise { + return toResponse(await client.getModels(sessionId)) +} + +export async function listDshModels(client = new DshWebClient()): Promise { + const host = await client.describe() + const sessions = await client.listSessions() + const catalogSession = sessions.find((session) => session.running) + ?? sessions.find((session) => !session.blank && session.origin !== 'subagent') + ?? sessions.find((session) => session.origin !== 'subagent') + + if (catalogSession) { + return await getDshModelsForSession(client, catalogSession.sessionId) + } + + return { + success: true, + current: { provider: host.provider, modelId: host.model }, + availableModels: [{ + provider: host.provider, + providerName: host.provider, + modelId: host.model, + name: host.model, + reasoningEfforts: [] + }] + } +} diff --git a/cli/src/dsh/dshSessions.test.ts b/cli/src/dsh/dshSessions.test.ts new file mode 100644 index 00000000..577834d6 --- /dev/null +++ b/cli/src/dsh/dshSessions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { listDshSessions } from './dshSessions' +import type { DshHistoryEntry, DshSessionSummary, DshWebClient } from './dshWebClient' + +describe('DeepSeek Harness session history', () => { + it('filters blank/subagent summaries and paginates selected history oldest-first', async () => { + const summaries: DshSessionSummary[] = [ + { sessionId: 'main', updatedAt: 30, running: false, blank: false, cwd: '/repo' }, + { sessionId: 'blank', updatedAt: 20, running: false, blank: true, cwd: '/repo' }, + { sessionId: 'child', updatedAt: 10, running: false, blank: false, cwd: '/repo', origin: 'subagent' } + ] + const calls: Array = [] + const entries = (seqs: number[]): DshHistoryEntry[] => seqs.map((seq) => ({ + event: { + type: 'user/message', + seq, + time: 1_000 + seq, + data: { source: { kind: 'user' }, content: [{ type: 'text', text: `m${seq}` }] } + } + })) + const client = { + baseUrl: 'http://127.0.0.1:3080', + describe: async () => ({}), + listSessions: async () => summaries, + getHistory: async ({ beforeSeq }: { beforeSeq?: number }) => { + calls.push(beforeSeq) + return beforeSeq === undefined + ? { entries: entries([5, 6]), hasMore: true } + : { entries: entries([1, 2]), hasMore: false } + } + } as unknown as DshWebClient + + const listed = await listDshSessions({ cwd: '/repo', client }) + expect(listed.sessions.map((session) => session.id)).toEqual(['main']) + + const selected = await listDshSessions({ sessionIds: new Set(['main']), client }) + expect(calls).toEqual([undefined, 5]) + expect(selected.sessions[0]).toMatchObject({ + id: 'main', + lastEventSeq: 6, + messageCount: 4 + }) + expect('messages' in selected.sessions[0]! + ? selected.sessions[0].messages.map((message) => message.eventSeq) + : []).toEqual([1, 2, 5, 6]) + }) +}) diff --git a/cli/src/dsh/dshSessions.ts b/cli/src/dsh/dshSessions.ts new file mode 100644 index 00000000..f85fd100 --- /dev/null +++ b/cli/src/dsh/dshSessions.ts @@ -0,0 +1,110 @@ +import type { + DshLocalSessionSummary, + DshLocalSessionWithMessages +} from '@hapi/protocol/apiTypes' +import { convertDshEvent, convertDshHistoryEntry } from './dshEvents' +import { DshWebClient, type DshHistoryEntry, type DshSessionSummary } from './dshWebClient' + +const HISTORY_PAGE_MESSAGES = 100 + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +function projectionTitle(summary: DshSessionSummary): string { + const title = summary.projections?.values.title + return typeof title === 'string' && title.trim() ? title.trim() : 'DeepSeek Harness session' +} + +function projectionMessageCount(summary: DshSessionSummary): number { + const stats = asRecord(summary.projections?.values.sessionStats) + const turns = stats?.turns + return typeof turns === 'number' && Number.isInteger(turns) && turns >= 0 ? turns : 0 +} + +function toSummary(summary: DshSessionSummary): DshLocalSessionSummary { + return { + id: summary.sessionId, + title: projectionTitle(summary), + cwd: summary.cwd ?? null, + modifiedAt: summary.updatedAt, + messageCount: projectionMessageCount(summary), + running: summary.running, + parentSessionId: summary.parentSessionId ?? null + } +} + +async function readAllHistory(client: DshWebClient, sessionId: string): Promise { + const pages: DshHistoryEntry[][] = [] + let beforeSeq: number | undefined + let lastMinimum = Number.POSITIVE_INFINITY + + while (true) { + const page = await client.getHistory({ + sessionId, + beforeSeq, + maxMessages: HISTORY_PAGE_MESSAGES + }) + if (page.entries.length === 0) break + const minimum = Math.min(...page.entries.map((entry) => entry.event.seq)) + if (minimum >= lastMinimum) throw new Error(`DeepSeek Harness history cursor did not advance for ${sessionId}`) + pages.unshift(page.entries) + if (!page.hasMore || minimum === 0) break + lastMinimum = minimum + beforeSeq = minimum + } + + return pages.flat() +} + +async function loadTranscript( + client: DshWebClient, + summary: DshSessionSummary +): Promise { + const entries = await readAllHistory(client, summary.sessionId) + const messages = entries.flatMap((entry) => convertDshHistoryEntry(summary.sessionId, entry)) + let model: string | null = null + let reasoningEffort: string | null = null + for (const entry of entries) { + const converted = convertDshEvent(entry.event, entry) + if (converted.model) model = converted.model + if (converted.reasoningEffort) reasoningEffort = converted.reasoningEffort + } + const lastUserMessage = [...messages].reverse().find((message) => message.content.role === 'user') + return { + ...toSummary(summary), + lastUserMessage: lastUserMessage?.content.role === 'user' + ? lastUserMessage.content.content.text + : null, + model, + reasoningEffort, + messageCount: messages.length, + messages, + lastEventSeq: entries.at(-1)?.event.seq ?? null + } +} + +export async function listDshSessions(options?: { + cwd?: string | null + sessionIds?: Set | null + client?: DshWebClient +}): Promise<{ sessions: Array; sourceUrl: string }> { + const client = options?.client ?? new DshWebClient() + await client.describe() + const requestedIds = options?.sessionIds ?? null + const cwd = options?.cwd?.trim() || null + const summaries = (await client.listSessions()).filter((summary) => { + if (requestedIds) return requestedIds.has(summary.sessionId) + if (summary.blank || summary.origin === 'subagent') return false + return !cwd || summary.cwd === cwd + }) + + if (!requestedIds) { + return { sessions: summaries.map(toSummary), sourceUrl: client.baseUrl } + } + + const sessions = await Promise.all(summaries.map((summary) => loadTranscript(client, summary))) + return { sessions, sourceUrl: client.baseUrl } +} diff --git a/cli/src/dsh/dshWebClient.test.ts b/cli/src/dsh/dshWebClient.test.ts new file mode 100644 index 00000000..d1e74601 --- /dev/null +++ b/cli/src/dsh/dshWebClient.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { DshWebClient, DshWebRpcError, resolveDshWebUrl } from './dshWebClient' +import { listDshModels } from './dshModels' + +function rpcFetch(values: Record): typeof fetch { + return (async (_input: string | URL | Request, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string } + const value = values[request.method] + return new Response(JSON.stringify({ + type: 'server-response', + rpcId: request.rpcId, + result: value instanceof Error + ? { ok: false, error: { code: 'failed', message: value.message } } + : { ok: true, value } + }), { status: 200, headers: { 'content-type': 'application/json' } }) + }) as typeof fetch +} + +describe('DshWebClient', () => { + it('normalizes the configured URL and rejects unsafe forms', () => { + expect(resolveDshWebUrl('http://127.0.0.1:3080/path')).toBe('http://127.0.0.1:3080') + expect(() => resolveDshWebUrl('ws://127.0.0.1:3080')).toThrow('http:// or https://') + expect(() => resolveDshWebUrl('http://user:pass@127.0.0.1:3080')).toThrow('must not contain credentials') + }) + + it('parses provider-qualified models and reasoning efforts', async () => { + const client = new DshWebClient('http://127.0.0.1:3080', rpcFetch({ + 'session.models': { + current: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ + id: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + reasoning: { + defaultEffort: 'high', + efforts: [{ id: 'high', name: 'High' }, { id: 'max', name: 'Max' }] + } + }] + }] + } + })) + + await expect(client.getModels('session-1')).resolves.toEqual({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }, + models: [{ + provider: 'deepseek-official', + providerName: 'DeepSeek', + model: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + reasoningEfforts: [ + { id: 'high', name: 'High', isDefault: true }, + { id: 'max', name: 'Max', isDefault: false } + ] + }] + }) + }) + + it('keeps DSH RPC errors structured', async () => { + const client = new DshWebClient('http://127.0.0.1:3080', rpcFetch({ + 'host.describe': new Error('not ready') + })) + const error = await client.describe().catch((value) => value) + expect(error).toBeInstanceOf(DshWebRpcError) + expect(error).toMatchObject({ method: 'host.describe', code: 'failed' }) + }) + + it('executes native slash commands and preserves success/error results', async () => { + const requests: Array<{ method: string; payload: unknown }> = [] + const client = new DshWebClient('http://127.0.0.1:3080', (async (_input, init) => { + const request = JSON.parse(String(init?.body)) as { rpcId: string; method: string; payload: unknown } + requests.push(request) + const value = request.method === 'commands/execute' + ? { + commandId: 'cmd-1', + result: { kind: 'success', text: 'Compacted 2 history items.', sourceEventSeq: 7 } + } + : undefined + return new Response(JSON.stringify({ + type: 'server-response', + rpcId: request.rpcId, + result: { ok: true, value } + }), { status: 200, headers: { 'content-type': 'application/json' } }) + }) as typeof fetch) + + await expect(client.executeCommand('session-1', '/compact')).resolves.toEqual({ + commandId: 'cmd-1', + result: { kind: 'success', text: 'Compacted 2 history items.', sourceEventSeq: 7 } + }) + expect(requests[0]).toMatchObject({ + method: 'commands/execute', + payload: { args: { agentId: 'session-1', line: '/compact', images: [] } } + }) + }) + + it('returns undefined for a DSH line that is not a native command', async () => { + const client = new DshWebClient('http://127.0.0.1:3080', rpcFetch({ 'commands/execute': undefined })) + await expect(client.executeCommand('session-1', '/unknown')).resolves.toBeUndefined() + }) + + it('preserves native command failures as settled error results', async () => { + const client = new DshWebClient('http://127.0.0.1:3080', rpcFetch({ + 'commands/execute': { + commandId: 'cmd-2', + result: { kind: 'error', text: 'Compaction is unavailable because the agent is busy.' } + } + })) + + await expect(client.executeCommand('session-1', '/compact')).resolves.toEqual({ + commandId: 'cmd-2', + result: { kind: 'error', text: 'Compaction is unavailable because the agent is busy.' } + }) + }) + + it('discovers the model catalog from an existing native session', async () => { + const client = new DshWebClient('http://127.0.0.1:3080', rpcFetch({ + 'host.describe': { + version: '0.0.1', + cwd: '/tmp/project', + provider: 'deepseek-official', + model: 'deepseek-v4-pro', + attachedSessions: 1 + }, + 'session.list': { + items: [{ + sessionId: 'session-1', + updatedAt: 1, + running: true, + blank: false + }] + }, + 'session.models': { + current: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ + id: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + reasoning: { + defaultEffort: 'max', + efforts: [{ id: 'max', name: 'Max' }] + } + }] + }] + } + })) + + await expect(listDshModels(client)).resolves.toEqual({ + success: true, + current: { + provider: 'deepseek-official', + modelId: 'deepseek-v4-pro', + reasoningEffort: 'max' + }, + availableModels: [{ + provider: 'deepseek-official', + providerName: 'DeepSeek', + modelId: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + reasoningEfforts: [{ id: 'max', name: 'Max', isDefault: true }] + }] + }) + }) +}) diff --git a/cli/src/dsh/dshWebClient.ts b/cli/src/dsh/dshWebClient.ts new file mode 100644 index 00000000..ecb173d0 --- /dev/null +++ b/cli/src/dsh/dshWebClient.ts @@ -0,0 +1,531 @@ +import { randomUUID } from 'node:crypto' + +type JsonRecord = Record + +export type DshSessionEvent = { + type: string + seq: number + time: number + data: unknown + sourceEventSeqs?: number[] + surfaceOp?: unknown + ignorable?: true +} + +export type DshHistoryEntry = { + event: DshSessionEvent + view?: unknown +} + +export type DshSessionSummary = { + sessionId: string + updatedAt: number + running: boolean + blank: boolean + parentSessionId?: string + origin?: 'subagent' + cwd?: string + agentPreset?: string + projections?: { + asOfSeq: number + values: Record + } +} + +export type DshMuxFrame = { + type: string + sessionId?: string + event?: DshSessionEvent + [key: string]: unknown +} + +export type DshServerRequest = { + type: 'server-request' + rpcId: string + method: string + payload: DshMuxFrame +} + +export type DshModelSelection = { + provider: string + model: string + reasoningEffort?: string +} + +export type DshModelSummary = { + provider: string + providerName: string + model: string + name: string + reasoningEfforts: Array<{ id: string; name: string; isDefault: boolean }> +} + +export type DshCommandResult = + | { kind: 'success'; text?: string; sourceEventSeq?: number } + | { kind: 'error'; text: string } + +export type DshCommandExecution = { + commandId: string + result: DshCommandResult +} + +export class DshWebRpcError extends Error { + constructor( + readonly method: string, + readonly code: string, + message: string, + readonly details?: unknown + ) { + super(`DeepSeek Harness ${method} failed: ${message}`) + this.name = 'DshWebRpcError' + } +} + +export function resolveDshWebUrl(value = process.env.HAPI_DSH_URL): string { + const candidate = value?.trim() || 'http://127.0.0.1:3080' + const url = new URL(candidate) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('HAPI_DSH_URL must use http:// or https://') + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('HAPI_DSH_URL must not contain credentials, query parameters, or a fragment') + } + url.pathname = '/' + return url.origin +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function parseSessionEvent(value: unknown): DshSessionEvent | null { + if (!isRecord(value)) return null + const type = asString(value.type) + const seq = asNumber(value.seq) + const time = asNumber(value.time) + if (!type || seq === null || time === null || seq < 0) return null + return { + type, + seq, + time, + data: value.data, + ...(Array.isArray(value.sourceEventSeqs) + ? { sourceEventSeqs: value.sourceEventSeqs.filter((entry): entry is number => typeof entry === 'number') } + : {}), + ...(value.surfaceOp !== undefined ? { surfaceOp: value.surfaceOp } : {}), + ...(value.ignorable === true ? { ignorable: true } : {}) + } +} + +function parseSessionSummary(value: unknown): DshSessionSummary | null { + if (!isRecord(value)) return null + const sessionId = asString(value.sessionId) + const updatedAt = asNumber(value.updatedAt) + if (!sessionId || updatedAt === null || typeof value.running !== 'boolean' || typeof value.blank !== 'boolean') { + return null + } + const projections = isRecord(value.projections) + && asNumber(value.projections.asOfSeq) !== null + && isRecord(value.projections.values) + ? { + asOfSeq: value.projections.asOfSeq as number, + values: value.projections.values + } + : undefined + return { + sessionId, + updatedAt, + running: value.running, + blank: value.blank, + ...(asString(value.parentSessionId) ? { parentSessionId: value.parentSessionId as string } : {}), + ...(value.origin === 'subagent' ? { origin: 'subagent' as const } : {}), + ...(asString(value.cwd) ? { cwd: value.cwd as string } : {}), + ...(asString(value.agentPreset) ? { agentPreset: value.agentPreset as string } : {}), + ...(projections ? { projections } : {}) + } +} + +export class DshWebClient { + readonly baseUrl: string + + constructor( + baseUrl = resolveDshWebUrl(), + private readonly fetchImpl: typeof fetch = fetch + ) { + this.baseUrl = resolveDshWebUrl(baseUrl) + } + + async describe(signal?: AbortSignal): Promise<{ + version: string + cwd: string + provider: string + model: string + attachedSessions: number + }> { + const { value } = await this.call('host.describe', {}, signal) + if (!isRecord(value)) throw new Error('DeepSeek Harness host.describe returned an invalid value') + const version = asString(value.version) + const cwd = asString(value.cwd) + const provider = asString(value.provider) + const model = asString(value.model) + const attachedSessions = asNumber(value.attachedSessions) + if (!version || !cwd || !provider || !model || attachedSessions === null) { + throw new Error('DeepSeek Harness host.describe is missing required fields') + } + return { version, cwd, provider, model, attachedSessions } + } + + async listSessions(signal?: AbortSignal): Promise { + const { value } = await this.call('session.list', {}, signal) + if (!isRecord(value) || !Array.isArray(value.items)) { + throw new Error('DeepSeek Harness session.list returned an invalid value') + } + const sessions = value.items.map(parseSessionSummary) + if (sessions.some((entry) => entry === null)) { + throw new Error('DeepSeek Harness session.list returned an invalid session row') + } + return sessions as DshSessionSummary[] + } + + async getHistory(options: { + sessionId: string + beforeSeq?: number + maxMessages?: number + signal?: AbortSignal + }): Promise<{ entries: DshHistoryEntry[]; hasMore: boolean; projections?: { asOfSeq: number; values: Record } }> { + const payload = { + sessionId: options.sessionId, + ...(options.beforeSeq !== undefined ? { beforeSeq: options.beforeSeq } : {}), + ...(options.maxMessages !== undefined ? { maxMessages: options.maxMessages } : {}) + } + const { value } = await this.call('session.history', payload, options.signal, 120_000) + if (!isRecord(value) || !Array.isArray(value.events) || typeof value.hasMore !== 'boolean') { + throw new Error('DeepSeek Harness session.history returned an invalid value') + } + const entries: DshHistoryEntry[] = [] + for (const raw of value.events) { + if (!isRecord(raw)) throw new Error('DeepSeek Harness session.history returned an invalid entry') + const event = parseSessionEvent(raw.event) + if (!event) throw new Error('DeepSeek Harness session.history returned an invalid event') + entries.push({ event, ...(raw.view !== undefined ? { view: raw.view } : {}) }) + } + const projections = isRecord(value.projections) + && asNumber(value.projections.asOfSeq) !== null + && isRecord(value.projections.values) + ? { asOfSeq: value.projections.asOfSeq as number, values: value.projections.values } + : undefined + return { entries, hasMore: value.hasMore, ...(projections ? { projections } : {}) } + } + + async createSession(options: { cwd: string; agentPreset?: string }, signal?: AbortSignal): Promise { + const { value } = await this.call('session.create', options, signal) + if (!isRecord(value) || !asString(value.sessionId)) { + throw new Error('DeepSeek Harness session.create returned an invalid session id') + } + return value.sessionId as string + } + + async prompt(options: { + sessionId: string + text: string + mode?: 'queue' | 'steer' + clientTimeZone?: string + rpcId?: string + signal?: AbortSignal + }): Promise<{ rpcId: string; command?: { text?: string } }> { + const { rpcId, value } = await this.call('session.prompt', { + sessionId: options.sessionId, + mode: options.mode ?? 'queue', + content: [{ type: 'text', text: options.text }], + ...(options.clientTimeZone ? { clientTimeZone: options.clientTimeZone } : {}) + }, options.signal, 120_000, options.rpcId) + if (!isRecord(value) || value.accepted !== true) { + throw new Error('DeepSeek Harness session.prompt was not accepted') + } + const command = isRecord(value.command) && value.command.kind === 'success' + ? { ...(typeof value.command.text === 'string' ? { text: value.command.text } : {}) } + : undefined + return { rpcId, ...(command ? { command } : {}) } + } + + /** + * Execute a native DSH slash command without creating a model turn. + * + * DSH returns `undefined` when the line is not a known command (or has + * invalid command syntax); callers can then fall back to a normal prompt. + */ + async executeCommand( + sessionId: string, + line: string, + signal?: AbortSignal + ): Promise { + const { value } = await this.call('commands/execute', { + args: { + agentId: sessionId, + line, + images: [] + } + }, signal, 300_000) + if (value === undefined || value === null) return undefined + if (!isRecord(value) || !asString(value.commandId) || !isRecord(value.result)) { + throw new Error('DeepSeek Harness commands/execute returned an invalid execution') + } + const result = value.result + const kind = result.kind + if (kind === 'success') { + const sourceEventSeq = result.sourceEventSeq + if (sourceEventSeq !== undefined && asNumber(sourceEventSeq) === null) { + throw new Error('DeepSeek Harness commands/execute returned an invalid source event sequence') + } + return { + commandId: value.commandId as string, + result: { + kind: 'success', + ...(typeof result.text === 'string' ? { text: result.text } : {}), + ...(sourceEventSeq !== undefined ? { sourceEventSeq: sourceEventSeq as number } : {}) + } + } + } + if (kind === 'error' && typeof result.text === 'string') { + return { + commandId: value.commandId as string, + result: { kind: 'error', text: result.text } + } + } + throw new Error('DeepSeek Harness commands/execute returned an invalid command result') + } + + async cancel(sessionId: string, signal?: AbortSignal): Promise { + const { value } = await this.call('session.cancel', { sessionId }, signal) + if (!isRecord(value) || value.accepted !== true) { + throw new Error('DeepSeek Harness session.cancel was not accepted') + } + } + + async getModels(sessionId: string, signal?: AbortSignal): Promise<{ + current: DshModelSelection + models: DshModelSummary[] + }> { + const { value } = await this.call('session.models', { sessionId }, signal) + if (!isRecord(value) || !isRecord(value.current) || !Array.isArray(value.groups)) { + throw new Error('DeepSeek Harness session.models returned an invalid value') + } + const provider = asString(value.current.provider) + const model = asString(value.current.model) + if (!provider || !model) { + throw new Error('DeepSeek Harness session.models is missing the current model') + } + + const models: DshModelSummary[] = [] + for (const rawGroup of value.groups) { + if (!isRecord(rawGroup) || !asString(rawGroup.id) || !asString(rawGroup.name) || !Array.isArray(rawGroup.models)) { + throw new Error('DeepSeek Harness session.models returned an invalid provider group') + } + for (const rawModel of rawGroup.models) { + if (!isRecord(rawModel) || !asString(rawModel.id) || !asString(rawModel.name)) { + throw new Error('DeepSeek Harness session.models returned an invalid model') + } + const reasoning = isRecord(rawModel.reasoning) ? rawModel.reasoning : null + const defaultEffort = reasoning ? asString(reasoning.defaultEffort) : null + const efforts = reasoning && Array.isArray(reasoning.efforts) + ? reasoning.efforts.map((rawEffort) => { + if (!isRecord(rawEffort) || !asString(rawEffort.id) || !asString(rawEffort.name)) { + throw new Error('DeepSeek Harness session.models returned an invalid reasoning effort') + } + return { + id: rawEffort.id as string, + name: rawEffort.name as string, + isDefault: rawEffort.id === defaultEffort + } + }) + : [] + models.push({ + provider: rawGroup.id as string, + providerName: rawGroup.name as string, + model: rawModel.id as string, + name: rawModel.name as string, + reasoningEfforts: efforts + }) + } + } + + return { + current: { + provider, + model, + ...(asString(value.current.reasoningEffort) + ? { reasoningEffort: value.current.reasoningEffort as string } + : {}) + }, + models + } + } + + async selectModel(options: { + sessionId: string + provider: string + model: string + reasoningEffort?: string + signal?: AbortSignal + }): Promise { + const { value } = await this.call('session.selectModel', { + sessionId: options.sessionId, + provider: options.provider, + model: options.model, + ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}) + }, options.signal) + const selected = isRecord(value) && isRecord(value.selected) ? value.selected : null + const provider = selected ? asString(selected.provider) : null + const model = selected ? asString(selected.model) : null + if (!selected || !provider || !model) { + throw new Error('DeepSeek Harness session.selectModel returned an invalid selection') + } + return { + provider, + model, + ...(asString(selected.reasoningEffort) + ? { reasoningEffort: selected.reasoningEffort as string } + : {}) + } + } + + async setPermissionPreset(sessionId: string, preset: string, signal?: AbortSignal): Promise { + await this.prompt({ + sessionId, + text: `/permission ${preset}`, + mode: 'queue', + signal + }) + } + + async respond(rpcId: string, value: unknown, signal?: AbortSignal): Promise { + await this.sendResponse(rpcId, { ok: true, value }, signal) + } + + async cancelResponse(rpcId: string, signal?: AbortSignal): Promise { + await this.sendResponse(rpcId, { + ok: false, + error: { code: 'cancelled', message: 'Cancelled by HAPI client' } + }, signal) + } + + private async sendResponse(rpcId: string, result: unknown, signal?: AbortSignal): Promise { + const response = await this.fetchImpl(new URL('/api/respond', this.baseUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-response', + rpcId, + result + }), + signal + }) + if (!response.ok) throw new Error(`DeepSeek Harness response transport failed: HTTP ${response.status}`) + const receipt: unknown = await response.json() + if (!isRecord(receipt) || receipt.accepted !== true) { + const reason = isRecord(receipt) ? asString(receipt.reason) : null + throw new Error(`DeepSeek Harness response was rejected${reason ? `: ${reason}` : ''}`) + } + } + + subscribeMux(options: { + signal: AbortSignal + onFrame: (request: DshServerRequest) => void + onError?: (error: Error) => void + }): Promise { + const url = new URL('/api/events.mux', this.baseUrl) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return new Promise((resolve, reject) => { + const socket = new WebSocket(url.toString()) + let opened = false + const abort = () => socket.close() + options.signal.addEventListener('abort', abort, { once: true }) + socket.onopen = () => { + opened = true + resolve() + } + socket.onmessage = (message) => { + try { + const parsed: unknown = JSON.parse(String(message.data)) + if (!isRecord(parsed) + || parsed.type !== 'server-request' + || !asString(parsed.rpcId) + || !asString(parsed.method) + || !isRecord(parsed.payload) + || !asString(parsed.payload.type)) { + throw new Error('invalid mux frame') + } + options.onFrame({ + type: 'server-request', + rpcId: parsed.rpcId as string, + method: parsed.method as string, + payload: parsed.payload as DshMuxFrame + }) + } catch (error) { + options.onError?.(error instanceof Error ? error : new Error(String(error))) + } + } + socket.onerror = () => { + const error = new Error('DeepSeek Harness event stream failed') + if (!opened) reject(error) + else options.onError?.(error) + } + socket.onclose = () => { + options.signal.removeEventListener('abort', abort) + if (!options.signal.aborted) { + options.onError?.(new Error('DeepSeek Harness event stream disconnected')) + } + } + }) + } + + private async call( + method: string, + payload: unknown, + signal?: AbortSignal, + timeoutMs = 30_000, + requestedRpcId?: string + ): Promise<{ rpcId: string; value: unknown }> { + const rpcId = requestedRpcId ?? randomUUID() + const timeout = AbortSignal.timeout(timeoutMs) + const combined = signal ? AbortSignal.any([timeout, signal]) : timeout + let response: Response + try { + response = await this.fetchImpl(new URL(`/api/${method}`, this.baseUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload }), + signal: combined + }) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Unable to reach DeepSeek Harness at ${this.baseUrl}. ` + + `Start it with \`dsh web --port ${new URL(this.baseUrl).port || '3080'}\` or set HAPI_DSH_URL. ${detail}` + ) + } + if (!response.ok) throw new Error(`DeepSeek Harness ${method} transport failed: HTTP ${response.status}`) + const body: unknown = await response.json() + if (!isRecord(body) || body.type !== 'server-response' || body.rpcId !== rpcId || !isRecord(body.result)) { + throw new Error(`DeepSeek Harness ${method} returned an invalid RPC envelope`) + } + if (body.result.ok !== true) { + const error = isRecord(body.result.error) ? body.result.error : {} + throw new DshWebRpcError( + method, + asString(error.code) ?? 'unknown', + asString(error.message) ?? 'Unknown error', + error.details + ) + } + return { rpcId, value: body.result.value } + } +} diff --git a/cli/src/dsh/runDsh.test.ts b/cli/src/dsh/runDsh.test.ts new file mode 100644 index 00000000..a20ce318 --- /dev/null +++ b/cli/src/dsh/runDsh.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + dshAnswerFromHapi, + isDshSlashCommand, + parseDshQuestions, + questionFingerprint, + resolveDshQuestionRequest, + shouldApplyDshPermissionPreset, + toHapiQuestionInput +} from './runDsh' + +describe('DeepSeek Harness slash commands', () => { + it('recognizes command lines without classifying ordinary prose', () => { + expect(isDshSlashCommand('/compact')).toBe(true) + expect(isDshSlashCommand(' /compact now')).toBe(true) + expect(isDshSlashCommand('/foo:bar')).toBe(true) + expect(isDshSlashCommand('please run /compact')).toBe(false) + expect(isDshSlashCommand(' /')).toBe(false) + }) +}) + +describe('DeepSeek Harness permission preset resume', () => { + it('does not resend the permission command when the native preset already matches', () => { + expect(shouldApplyDshPermissionPreset('danger-full-access', 'danger-full-access')).toBe(false) + expect(shouldApplyDshPermissionPreset('workspace-write', 'workspace-write')).toBe(false) + expect(shouldApplyDshPermissionPreset('read-only', 'read-only')).toBe(false) + }) + + it('applies a requested preset when the native preset differs or is unavailable', () => { + expect(shouldApplyDshPermissionPreset('danger-full-access', 'workspace-write')).toBe(true) + expect(shouldApplyDshPermissionPreset('danger-full-access', null)).toBe(true) + }) + + it('leaves the native preset unchanged when HAPI requests the default mode', () => { + expect(shouldApplyDshPermissionPreset('default', 'danger-full-access')).toBe(false) + expect(shouldApplyDshPermissionPreset(undefined, 'danger-full-access')).toBe(false) + }) +}) + +describe('DeepSeek Harness user questions', () => { + const questions = parseDshQuestions([{ + id: 'audience', + header: '发送对象', + question: '这条站内信发给谁?', + detail: '选择最适合的受众。', + multi_select: true, + options: [ + { label: 'App 终端用户', description: '面向创作者' }, + { label: '同事/内部群', description: '面向内部协作' } + ] + }]) + + it('preserves DSH ids, details, options and multi-select input', () => { + expect(questions).toEqual([{ + id: 'audience', + header: '发送对象', + question: '这条站内信发给谁?', + detail: '选择最适合的受众。', + multiSelect: true, + options: [ + { label: 'App 终端用户', description: '面向创作者' }, + { label: '同事/内部群', description: '面向内部协作' } + ] + }]) + expect(toHapiQuestionInput(questions)).toMatchObject({ + questions: [{ id: 'audience', multiSelect: true }] + }) + }) + + it('maps HAPI index-keyed answers back to DSH stable question ids', () => { + expect(dshAnswerFromHapi(questions, { '0': ['App 终端用户'] })).toEqual({ + answers: [{ id: 'audience', selected: ['App 终端用户'] }] + }) + }) + + it('separates a custom answer from selected option labels', () => { + expect(dshAnswerFromHapi(questions, { + audience: ['同事/内部群', '同时发给运营'] + })).toEqual({ + answers: [{ + id: 'audience', + selected: ['同事/内部群'], + custom: '同时发给运营' + }] + }) + }) + + it('encodes a skipped DSH question as an empty selection', () => { + expect(dshAnswerFromHapi(questions, { audience: [] })).toEqual({ + answers: [{ id: 'audience', selected: [] }] + }) + }) + + it('uses the matching tool call id and reuses a pending request after reconnect', () => { + const fingerprint = questionFingerprint(questions) + const toolCalls = new Map([[fingerprint, 'tool-call-1']]) + expect(resolveDshQuestionRequest( + questions, + 'rpc-1', + toolCalls, + new Map(), + 100 + )).toEqual({ requestId: 'tool-call-1', createdAt: 100 }) + + expect(resolveDshQuestionRequest( + questions, + 'rpc-2', + new Map(), + new Map([[fingerprint, { id: 'existing-request', createdAt: 42 }]]), + 200 + )).toEqual({ requestId: 'existing-request', createdAt: 42 }) + }) +}) diff --git a/cli/src/dsh/runDsh.ts b/cli/src/dsh/runDsh.ts index ed3004cf..d59547b1 100644 --- a/cli/src/dsh/runDsh.ts +++ b/cli/src/dsh/runDsh.ts @@ -1,28 +1,238 @@ -import { hashObject } from '@/utils/deterministicJson' -import { MessageQueue2 } from '@/utils/MessageQueue2' -import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler' -import { createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle' import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory' +import { convertAgentMessage } from '@/agent/messageConverter' +import { createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle' +import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc' +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler' import { formatMessageWithAttachments } from '@/utils/attachmentFormatter' +import { hashObject } from '@/utils/deterministicJson' import { getInvokedCwd } from '@/utils/invokedCwd' +import { MessageQueue2 } from '@/utils/MessageQueue2' import { logger } from '@/ui/logger' -import type { AgentState } from '@/api/types' -import { DshRemoteLauncher } from './dshRemoteLauncher' -import { DshSession } from './session' -import type { DshMode } from './types' +import { randomUUID } from 'node:crypto' +import type { DshPermissionMode } from '@hapi/protocol' +import type { DshModelsResponse } from '@hapi/protocol/apiTypes' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { convertDshEvent } from './dshEvents' +import { getDshModelsForSession } from './dshModels' +import { + DshWebClient, + type DshModelSelection, + type DshModelSummary, + type DshServerRequest, + type DshSessionEvent +} from './dshWebClient' + +type DshQueueMode = { + deliveryMode: 'queue' | 'steer' +} + +type DshNativePermissionMode = Exclude + +type PendingTurn = { + userSeen: boolean + resolve: () => void + reject: (error: Error) => void +} + +type PermissionResponseMessage = { + id: string + approved: boolean + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + answers?: Record | Record +} + +export type DshQuestion = { + id: string + question: string + header?: string + detail?: string + options?: Array<{ label: string; description?: string }> + multiSelect?: boolean +} + +type PendingQuestion = { + rpcId: string + questions: DshQuestion[] + createdAt: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function eventSourceRpcId(event: DshSessionEvent): string | null { + if (event.type !== 'user/message' || !isRecord(event.data) || !isRecord(event.data.source)) return null + return typeof event.data.source.rpcId === 'string' ? event.data.source.rpcId : null +} + +function eventPermissionPreset(event: DshSessionEvent): DshNativePermissionMode | null { + if (event.type !== 'permission/preset' || !isRecord(event.data)) return null + const preset = event.data.preset + return preset === 'read-only' || preset === 'workspace-write' || preset === 'danger-full-access' + ? preset + : null +} + +function summaryPermissionPreset(value: unknown): DshNativePermissionMode | null { + if (!isRecord(value)) return null + const preset = value.currentValue + return preset === 'read-only' || preset === 'workspace-write' || preset === 'danger-full-access' + ? preset + : null +} + +export function parseDshQuestions(value: unknown): DshQuestion[] { + if (!Array.isArray(value)) return [] + return value.flatMap((raw) => { + if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.question !== 'string') return [] + const options = Array.isArray(raw.options) + ? raw.options.flatMap((option) => { + if (!isRecord(option) || typeof option.label !== 'string') return [] + return [{ + label: option.label, + ...(typeof option.description === 'string' ? { description: option.description } : {}) + }] + }) + : undefined + return [{ + id: raw.id, + question: raw.question, + ...(typeof raw.header === 'string' ? { header: raw.header } : {}), + ...(typeof raw.detail === 'string' ? { detail: raw.detail } : {}), + ...(options ? { options } : {}), + ...(typeof raw.multiSelect === 'boolean' + ? { multiSelect: raw.multiSelect } + : typeof raw.multi_select === 'boolean' + ? { multiSelect: raw.multi_select } + : {}) + }] + }) +} + +function parseDshToolArguments(value: unknown): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) as unknown + } catch { + return null + } +} + +export function questionFingerprint(questions: DshQuestion[]): string { + return JSON.stringify(questions.map((question) => ({ + id: question.id, + question: question.question, + header: question.header ?? null, + detail: question.detail ?? null, + options: question.options ?? [], + multiSelect: question.multiSelect === true + }))) +} + +export function toHapiQuestionInput(questions: DshQuestion[]): Record { + return { + questions: questions.map((question) => ({ + id: question.id, + question: question.question, + ...(question.header ? { header: question.header } : {}), + ...(question.detail ? { detail: question.detail } : {}), + ...(question.options ? { + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}) + })) + } : {}), + ...(question.multiSelect === true ? { multiSelect: true } : {}) + })) + } +} + +export function dshAnswerFromHapi( + questions: DshQuestion[], + answers: PermissionResponseMessage['answers'] +): { answers: Array<{ id: string; selected: string[]; custom?: string }> } { + return { + answers: questions.map((question, index) => { + const rawValue = answers?.[question.id] ?? answers?.[String(index)] ?? [] + const raw = Array.isArray(rawValue) ? rawValue : rawValue.answers + const allowed = new Set(question.options?.map((option) => option.label) ?? []) + const selected = raw.filter((value) => allowed.has(value)) + const customValues = raw.filter((value) => !allowed.has(value) && value.trim().length > 0) + return { + id: question.id, + selected, + ...(customValues.length > 0 ? { custom: customValues.join(', ') } : {}) + } + }) + } +} + +export function resolveDshQuestionRequest( + questions: DshQuestion[], + rpcId: string, + questionToolCalls: Map, + resumableRequests: Map, + now: number +): { requestId: string; createdAt: number } { + const fingerprint = questionFingerprint(questions) + const toolCallId = questionToolCalls.get(fingerprint) + const resumed = resumableRequests.get(fingerprint) + questionToolCalls.delete(fingerprint) + resumableRequests.delete(fingerprint) + return { + requestId: resumed?.id ?? toolCallId ?? rpcId, + createdAt: resumed?.createdAt ?? now + } +} + +export function shouldApplyDshPermissionPreset( + requested: DshPermissionMode | undefined, + nativeCurrent: DshNativePermissionMode | null +): requested is DshNativePermissionMode { + return requested !== undefined + && requested !== 'default' + && requested !== nativeCurrent +} + +/** Native DSH commands must never be merged into a model prompt batch. */ +export function isDshSlashCommand(text: string): boolean { + return /^\s*\/[a-z0-9:_-]+(?:\s|$)/i.test(text) +} + +function resolveRequestedModel( + requested: string, + models: readonly DshModelSummary[], + preferredProvider?: string +): DshModelSummary { + const exactRoute = models.find((entry) => `${entry.provider}/${entry.model}` === requested) + if (exactRoute) return exactRoute + const byModel = models.filter((entry) => entry.model === requested) + if (byModel.length === 1) return byModel[0]! + if (byModel.length > 1) { + const preferred = preferredProvider + ? byModel.find((entry) => entry.provider === preferredProvider) + : null + if (preferred) return preferred + throw new Error(`DeepSeek Harness model ${requested} exists in multiple providers; use provider/model`) + } + throw new Error(`DeepSeek Harness model not found: ${requested}`) +} export async function runDsh(opts: { startedBy?: 'runner' | 'terminal' - startingMode?: 'remote' + startingMode?: 'local' | 'remote' + permissionMode?: DshPermissionMode + model?: string + modelReasoningEffort?: string + resumeSessionId?: string existingSessionId?: string workingDirectory?: string } = {}): Promise { const workingDirectory = opts.workingDirectory ?? getInvokedCwd() const startedBy = opts.startedBy ?? 'terminal' - const startingMode = 'remote' as const - const initialState: AgentState = { - controlledByUser: false, - startingMode + + if (opts.startingMode === 'local') { + logger.debug('[dsh] Local mode requested; forcing remote because DSH Web owns the native UI') } const bootstrap = opts.existingSessionId @@ -36,54 +246,687 @@ export async function runDsh(opts: { flavor: 'dsh', startedBy, workingDirectory, - agentState: initialState + model: opts.model, + modelReasoningEffort: opts.modelReasoningEffort }) - const { api, session } = bootstrap - setControlledByUser(session, startingMode) + const { session, sessionInfo } = bootstrap + setControlledByUser(session, 'remote') - const queue = new MessageQueue2((mode) => hashObject(mode)) - const sessionRef: { current: DshSession | null } = { current: null } - const launcherRef: { current: DshRemoteLauncher | null } = { current: null } + const client = new DshWebClient() + await client.describe() - session.onUserMessage((message, localId) => { - queue.push( - formatMessageWithAttachments(message.content.text, message.content.attachments), - 'dsh', - localId - ) - }) - session.onCancelQueuedMessage((localId) => queue.cancelByLocalId(localId)) + const muxAbort = new AbortController() + const loopAbort = new AbortController() + const queue = new MessageQueue2((mode) => hashObject(mode)) + const pendingTurns = new Map() + const pendingQuestions = new Map() + const questionToolCalls = new Map() + const resumableQuestionRequests = new Map() + const ownedRpcIds = new Set() + const pendingApprovals = new Map() + + for (const [id, request] of Object.entries(sessionInfo.agentState?.requests ?? {})) { + if (request.tool !== 'ask_user_question' || !isRecord(request.arguments)) continue + const questions = parseDshQuestions(request.arguments.questions) + if (questions.length === 0) continue + resumableQuestionRequests.set(questionFingerprint(questions), { + id, + createdAt: request.createdAt ?? Date.now() + }) + } + + let nativeSessionId = opts.resumeSessionId ?? sessionInfo.metadata?.dshSessionId ?? null + let thinking = false + let stopped = false + let currentPermissionMode: DshPermissionMode = opts.permissionMode + ?? (sessionInfo.permissionMode as DshPermissionMode | undefined) + ?? 'default' + let nativeDefaultPermissionMode: DshNativePermissionMode | null = null + let currentModel: string | null = sessionInfo.model ?? opts.model ?? null + let currentReasoningEffort: string | null = sessionInfo.modelReasoningEffort ?? opts.modelReasoningEffort ?? null + let currentSelection: DshModelSelection | null = null + let nativeTurnStateObserved = false + let keepAliveInterval: ReturnType | null = null + let latestEventSeq = sessionInfo.metadata?.dshHistoryLastEventSeq ?? -1 + + const syncKeepAlive = () => { + session.keepAlive(thinking, 'remote', { + permissionMode: currentPermissionMode, + model: currentModel, + modelReasoningEffort: currentReasoningEffort + }) + } + + const finishPendingApprovals = (reason: string) => { + const now = Date.now() + session.updateAgentState((state) => { + const completedRequests = { ...state.completedRequests } + for (const [id, pending] of pendingApprovals) { + completedRequests[id] = { + tool: pending.toolName, + arguments: pending.arguments, + createdAt: pending.createdAt, + completedAt: now, + status: 'canceled', + reason, + decision: 'abort' + } + } + return { ...state, requests: {}, completedRequests } + }) + pendingApprovals.clear() + } + + const finishPendingQuestions = (reason: string) => { + const now = Date.now() + for (const pending of pendingQuestions.values()) { + void client.cancelResponse(pending.rpcId).catch((error) => { + logger.debug('[dsh] Failed to cancel pending user question:', error) + }) + } + session.updateAgentState((state) => { + const completedRequests = { ...state.completedRequests } + const requests = { ...state.requests } + for (const [id, pending] of pendingQuestions) { + delete requests[id] + completedRequests[id] = { + tool: 'ask_user_question', + arguments: toHapiQuestionInput(pending.questions), + createdAt: pending.createdAt, + completedAt: now, + status: 'canceled', + reason, + decision: 'abort' + } + } + return { ...state, requests, completedRequests } + }) + pendingQuestions.clear() + questionToolCalls.clear() + } + + const failPendingTurns = (error: Error) => { + for (const pending of pendingTurns.values()) pending.reject(error) + pendingTurns.clear() + } const lifecycle = createRunnerLifecycle({ session, logTag: 'dsh', - stopKeepAlive: () => sessionRef.current?.stopKeepAlive(), - onBeforeClose: () => launcherRef.current?.kill() + stopKeepAlive: () => { + if (keepAliveInterval) clearInterval(keepAliveInterval) + keepAliveInterval = null + }, + onBeforeClose: async () => { + stopped = true + queue.close() + loopAbort.abort() + muxAbort.abort() + failPendingTurns(new Error('DeepSeek Harness session stopped')) + finishPendingApprovals('Session stopped') + finishPendingQuestions('Session stopped') + if (thinking && nativeSessionId) { + try { + await client.cancel(nativeSessionId) + } catch (error) { + logger.debug('[dsh] Failed to cancel native turn during shutdown:', error) + } + } + } }) lifecycle.registerProcessHandlers() registerKillSessionHandler(session.rpcHandlerManager, lifecycle) - const dshSession = new DshSession({ - api, - client: session, - path: workingDirectory, - logPath: logger.getLogPath(), - messageQueue: queue, - onModeChange: () => {}, - startedBy - }) - const launcher = new DshRemoteLauncher(dshSession) - sessionRef.current = dshSession - launcherRef.current = launcher + const handleMuxFrame = (request: DshServerRequest) => { + const frame = request.payload + if (frame.sessionId !== nativeSessionId) return + + if (frame.type === 'approval/requested') { + const approvalId = typeof frame.approvalId === 'string' ? frame.approvalId : null + const toolName = typeof frame.toolName === 'string' ? frame.toolName : 'DeepSeek Harness tool' + if (!approvalId || pendingApprovals.has(approvalId)) return + const createdAt = Date.now() + const argumentsValue = { + ...(typeof frame.callId === 'string' ? { callId: frame.callId } : {}), + ...(typeof frame.reason === 'string' ? { reason: frame.reason } : {}) + } + pendingApprovals.set(approvalId, { + rpcId: request.rpcId, + toolName, + arguments: argumentsValue, + createdAt + }) + session.updateAgentState((state) => ({ + ...state, + requests: { + ...state.requests, + [approvalId]: { tool: toolName, arguments: argumentsValue, createdAt } + } + })) + return + } + + if (frame.type === 'approval/resolved') { + const approvalId = typeof frame.approvalId === 'string' ? frame.approvalId : null + if (!approvalId) return + const pending = pendingApprovals.get(approvalId) + if (!pending) return + pendingApprovals.delete(approvalId) + const approved = frame.outcome === 'allowed-once' + session.updateAgentState((state) => { + const { [approvalId]: _, ...requests } = state.requests ?? {} + return { + ...state, + requests, + completedRequests: { + ...state.completedRequests, + [approvalId]: { + tool: pending.toolName, + arguments: pending.arguments, + createdAt: pending.createdAt, + completedAt: Date.now(), + status: approved ? 'approved' : 'denied', + decision: approved ? 'approved' : 'denied' + } + } + } + }) + return + } + + if (frame.type === 'question/requested') { + const questions = parseDshQuestions(frame.questions) + if (questions.length === 0) { + session.sendSessionEvent({ type: 'error', message: 'DeepSeek Harness sent an invalid user question.' }) + void client.cancelResponse(request.rpcId).catch((error) => { + logger.debug('[dsh] Failed to cancel invalid user question:', error) + }) + return + } + const { requestId, createdAt } = resolveDshQuestionRequest( + questions, + request.rpcId, + questionToolCalls, + resumableQuestionRequests, + Date.now() + ) + thinking = false + session.updateAgentState((state) => ({ + ...state, + requests: { + ...state.requests, + [requestId]: { + tool: 'ask_user_question', + arguments: toHapiQuestionInput(questions), + createdAt + } + } + })) + pendingQuestions.set(requestId, { rpcId: request.rpcId, questions, createdAt }) + syncKeepAlive() + return + } + + if (frame.type === 'question/resolved') { + const questionRpcId = typeof frame.questionRpcId === 'string' ? frame.questionRpcId : null + if (questionRpcId) { + const match = Array.from(pendingQuestions.entries()) + .find(([, pending]) => pending.rpcId === questionRpcId) + if (match) { + const [requestId, pending] = match + pendingQuestions.delete(requestId) + thinking = true + session.updateAgentState((state) => { + const { [requestId]: _, ...requests } = state.requests ?? {} + return { + ...state, + requests, + completedRequests: { + ...state.completedRequests, + [requestId]: { + tool: 'ask_user_question', + arguments: toHapiQuestionInput(pending.questions), + createdAt: pending.createdAt, + completedAt: Date.now(), + status: frame.outcome === 'answered' ? 'approved' : 'canceled', + decision: frame.outcome === 'answered' ? 'approved' : 'abort' + } + } + } + }) + syncKeepAlive() + } + } + return + } + + if (frame.type !== 'session/event' || !frame.event) return + const event = frame.event + if (event.type === 'tool/call' && isRecord(event.data) + && event.data.name === 'ask_user_question' + && typeof event.data.callId === 'string') { + const input = parseDshToolArguments(event.data.arguments) + const questions = isRecord(input) ? parseDshQuestions(input.questions) : [] + if (questions.length > 0) { + questionToolCalls.set(questionFingerprint(questions), event.data.callId) + } + } + latestEventSeq = Math.max(latestEventSeq, event.seq) + const sourceRpcId = eventSourceRpcId(event) + if (sourceRpcId) { + const pending = pendingTurns.get(sourceRpcId) + if (pending) pending.userSeen = true + } + + const preset = eventPermissionPreset(event) + if (preset) { + currentPermissionMode = preset + session.sendSessionEvent({ type: 'permission-mode-changed', mode: preset }) + } + + if (event.type === 'turn/start') { + nativeTurnStateObserved = true + thinking = true + } else if (event.type === 'turn/end') { + nativeTurnStateObserved = true + thinking = false + questionToolCalls.clear() + resumableQuestionRequests.clear() + session.updateMetadata((metadata) => ({ + ...metadata, + dshHistoryLastEventSeq: latestEventSeq, + ...(metadata.dshImportState ? { + dshImportState: { + ...metadata.dshImportState, + updatedAt: Date.now(), + lastEventSeq: latestEventSeq + } + } : {}) + })) + for (const [rpcId, pending] of pendingTurns) { + if (!pending.userSeen) continue + pendingTurns.delete(rpcId) + pending.resolve() + } + if (queue.size() === 0) session.sendSessionEvent({ type: 'ready' }) + } + + const converted = convertDshEvent(event, { event, ...(frame.view !== undefined ? { view: frame.view } : {}) }) + if (converted.model) currentModel = converted.model + if (converted.reasoningEffort) currentReasoningEffort = converted.reasoningEffort + + if (converted.humanText && (!sourceRpcId || !ownedRpcIds.has(sourceRpcId))) { + session.sendUserMessage(converted.humanText) + } + for (const message of converted.messages) { + const body = convertAgentMessage(message, converted.model ?? currentModel ?? undefined) + if (body) session.sendAgentMessage(body) + } + for (const eventData of converted.events ?? []) { + session.sendSessionEvent(eventData) + } + syncKeepAlive() + } - let crashed = false try { - await launcher.launch() + await client.subscribeMux({ + signal: muxAbort.signal, + onFrame: handleMuxFrame, + onError: (error) => { + if (stopped || muxAbort.signal.aborted) return + session.sendSessionEvent({ type: 'error', message: error.message }) + failPendingTurns(error) + loopAbort.abort(error) + } + }) + + let nativeSummary + if (!nativeSessionId) { + nativeSessionId = await client.createSession({ cwd: workingDirectory }) + nativeSummary = (await client.listSessions()).find((entry) => entry.sessionId === nativeSessionId) + } else { + nativeSummary = (await client.listSessions()).find((entry) => entry.sessionId === nativeSessionId) + if (!nativeSummary) throw new Error(`DeepSeek Harness session not found: ${nativeSessionId}`) + } + if (!nativeTurnStateObserved) thinking = nativeSummary?.running === true + + const projectedPermission = summaryPermissionPreset(nativeSummary?.projections?.values.permissions) + if (projectedPermission) { + nativeDefaultPermissionMode = projectedPermission + currentPermissionMode = projectedPermission + } + + session.updateMetadata((metadata) => ({ ...metadata, dshSessionId: nativeSessionId! })) + + const catalog = await client.getModels(nativeSessionId) + currentSelection = catalog.current + currentModel = currentSelection.model + currentReasoningEffort = currentSelection.reasoningEffort ?? null + + session.rpcHandlerManager.registerHandler, DshModelsResponse>( + RPC_METHODS.ListDshModels, + async () => { + try { + return await getDshModelsForSession(client, nativeSessionId!) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list DeepSeek Harness models' + } + } + } + ) + + if (opts.model || opts.modelReasoningEffort) { + const selectedModel = opts.model + ? opts.model === currentSelection.model + ? resolveRequestedModel(`${currentSelection.provider}/${currentSelection.model}`, catalog.models) + : resolveRequestedModel(opts.model, catalog.models) + : resolveRequestedModel(`${currentSelection.provider}/${currentSelection.model}`, catalog.models) + currentSelection = await client.selectModel({ + sessionId: nativeSessionId, + provider: selectedModel.provider, + model: selectedModel.model, + ...(opts.modelReasoningEffort ? { reasoningEffort: opts.modelReasoningEffort } : {}) + }) + currentModel = currentSelection.model + currentReasoningEffort = currentSelection.reasoningEffort ?? null + } + + if (shouldApplyDshPermissionPreset(opts.permissionMode, projectedPermission)) { + await client.setPermissionPreset(nativeSessionId, opts.permissionMode) + currentPermissionMode = opts.permissionMode + } + + registerSessionConfigRpc({ + rpcHandlerManager: session.rpcHandlerManager, + flavor: 'dsh', + modelMode: 'nullable', + modelReasoningEffortMode: 'nullable', + onApply: async (config) => { + if (config.permissionMode !== undefined) { + const targetPermissionMode = config.permissionMode === 'default' + ? nativeDefaultPermissionMode + : config.permissionMode + if (!targetPermissionMode) { + throw new Error('DeepSeek Harness default permission preset is unavailable') + } + if (targetPermissionMode !== currentPermissionMode) { + await client.setPermissionPreset(nativeSessionId!, targetPermissionMode) + } + currentPermissionMode = targetPermissionMode + } + if (config.model !== undefined || config.modelReasoningEffort !== undefined) { + const latest = await client.getModels(nativeSessionId!) + const requestedModel = config.model ?? currentSelection?.model ?? latest.current.model + const selectedModel = resolveRequestedModel( + requestedModel, + latest.models, + currentSelection?.provider ?? latest.current.provider + ) + currentSelection = await client.selectModel({ + sessionId: nativeSessionId!, + provider: selectedModel.provider, + model: selectedModel.model, + ...(config.modelReasoningEffort + ? { reasoningEffort: config.modelReasoningEffort } + : {}) + }) + currentModel = currentSelection.model + currentReasoningEffort = currentSelection.reasoningEffort ?? null + } + }, + onAfterApply: syncKeepAlive, + appliedFallback: () => ({ + permissionMode: currentPermissionMode, + model: currentModel, + modelReasoningEffort: currentReasoningEffort + }) + }) + + session.rpcHandlerManager.registerHandler( + RPC_METHODS.Permission, + async (response) => { + const question = pendingQuestions.get(response.id) + if (question) { + pendingQuestions.delete(response.id) + const answers = response.answers + thinking = true + syncKeepAlive() + try { + await client.respond(question.rpcId, { + sessionId: nativeSessionId, + answer: dshAnswerFromHapi(question.questions, answers) + }) + session.updateAgentState((state) => { + const { [response.id]: _, ...requests } = state.requests ?? {} + return { + ...state, + requests, + completedRequests: { + ...state.completedRequests, + [response.id]: { + tool: 'ask_user_question', + arguments: toHapiQuestionInput(question.questions), + createdAt: question.createdAt, + completedAt: Date.now(), + status: 'approved', + decision: 'approved', + answers + } + } + } + }) + } catch (error) { + session.updateAgentState((state) => { + const { [response.id]: _, ...requests } = state.requests ?? {} + return { + ...state, + requests, + completedRequests: { + ...state.completedRequests, + [response.id]: { + tool: 'ask_user_question', + arguments: toHapiQuestionInput(question.questions), + createdAt: question.createdAt, + completedAt: Date.now(), + status: 'canceled', + reason: error instanceof Error ? error.message : String(error), + decision: 'abort', + answers + } + } + } + }) + throw error + } + return + } + const pending = pendingApprovals.get(response.id) + if (!pending) return + const outcome = response.approved ? 'allowed-once' : 'rejected' + await client.respond(pending.rpcId, { + sessionId: nativeSessionId, + approvalId: response.id, + outcome + }) + if (response.decision === 'abort') await client.cancel(nativeSessionId!) + } + ) + + session.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => { + if (!nativeSessionId) return + await client.cancel(nativeSessionId) + thinking = false + syncKeepAlive() + session.sendSessionEvent({ type: 'ready' }) + }) + + const submitSteer = async (text: string, localId?: string): Promise => { + const requestedRpcId = randomUUID() + ownedRpcIds.add(requestedRpcId) + const completion = new Promise((resolve, reject) => { + pendingTurns.set(requestedRpcId, { userSeen: false, resolve, reject }) + }) + try { + const { rpcId, command } = await client.prompt({ + sessionId: nativeSessionId!, + text, + mode: 'steer', + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + rpcId: requestedRpcId + }) + if (localId) session.emitMessagesConsumed([localId]) + if (command) { + pendingTurns.delete(rpcId) + ownedRpcIds.delete(rpcId) + if (command.text) session.sendSessionEvent({ type: 'message', message: command.text }) + return + } + void completion.then( + () => ownedRpcIds.delete(rpcId), + (error) => { + ownedRpcIds.delete(rpcId) + logger.debug('[dsh] Steered prompt completion failed:', error) + } + ) + } catch (error) { + pendingTurns.delete(requestedRpcId) + ownedRpcIds.delete(requestedRpcId) + throw error + } + } + + session.rpcHandlerManager.registerHandler(RPC_METHODS.SteerQueuedMessage, async (payload: unknown) => { + const localId = isRecord(payload) && typeof payload.localId === 'string' + ? payload.localId + : null + if (!localId) return { steered: false, error: 'localId is required' } + if (!thinking) return { steered: false, error: 'Session is not running a turn' } + + const queued = queue.queue.find((item) => item.localId === localId) + if (!queued || !queue.cancelByLocalId(localId)) { + return { steered: false, error: 'Message not found or already dispatched' } + } + + try { + await submitSteer(queued.message, localId) + return { steered: true } + } catch (error) { + queue.unshift(queued.message, queued.mode, localId) + return { + steered: false, + error: error instanceof Error ? error.message : 'DeepSeek Harness steer failed' + } + } + }) + + session.onCancelQueuedMessage((localId) => queue.cancelByLocalId(localId)) + session.onUserMessage((message, localId) => { + const text = formatMessageWithAttachments(message.content.text, message.content.attachments) + const deliveryMode = message.meta?.deliveryMode ?? 'queue' + if (isDshSlashCommand(message.content.text)) { + // Native commands (notably /compact) are direct DSH RPCs. Keep + // them isolated so they retain FIFO order and never get merged + // with adjacent prompts or steered into an active turn. + queue.pushIsolated(text, { deliveryMode }, localId) + return + } + if (thinking && deliveryMode === 'steer') { + void submitSteer(text, localId).catch((error) => { + logger.debug('[dsh] Native steer submission failed; restoring queue item:', error) + queue.unshift(text, { deliveryMode }, localId) + }) + return + } + queue.push(text, { deliveryMode }, localId) + }) + + syncKeepAlive() + keepAliveInterval = setInterval(syncKeepAlive, 2_000) + session.sendSessionEvent({ type: 'ready' }) + + while (!stopped && !loopAbort.signal.aborted) { + const batch = await queue.waitForMessagesAndGetAsString(loopAbort.signal) + if (!batch) break + + try { + if (isDshSlashCommand(batch.message)) { + thinking = true + syncKeepAlive() + const execution = await client.executeCommand( + nativeSessionId!, + batch.message, + loopAbort.signal + ) + if (execution) { + session.emitMessagesConsumed(batch.items.flatMap((item) => item.localId ? [item.localId] : [])) + thinking = false + syncKeepAlive() + if (execution.result.text) { + session.sendSessionEvent({ type: 'message', message: execution.result.text }) + } + session.sendSessionEvent({ type: 'ready' }) + continue + } + // Unknown/invalid DSH command lines return no execution; + // preserve the existing pass-through behavior by sending + // them as ordinary model text below. + } + const requestedRpcId = randomUUID() + ownedRpcIds.add(requestedRpcId) + const completion = new Promise((resolve, reject) => { + pendingTurns.set(requestedRpcId, { userSeen: false, resolve, reject }) + }) + const { rpcId, command } = await client.prompt({ + sessionId: nativeSessionId, + text: batch.message, + mode: batch.mode.deliveryMode, + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + rpcId: requestedRpcId + }) + session.emitMessagesConsumed(batch.items.flatMap((item) => item.localId ? [item.localId] : [])) + if (command) { + pendingTurns.delete(rpcId) + ownedRpcIds.delete(rpcId) + if (command.text) session.sendSessionEvent({ type: 'message', message: command.text }) + thinking = false + syncKeepAlive() + session.sendSessionEvent({ type: 'ready' }) + continue + } + thinking = true + syncKeepAlive() + + await completion + ownedRpcIds.delete(rpcId) + } catch (error) { + for (const rpcId of ownedRpcIds) { + if (!pendingTurns.has(rpcId)) continue + pendingTurns.delete(rpcId) + ownedRpcIds.delete(rpcId) + } + for (let index = batch.items.length - 1; index >= 0; index -= 1) { + const item = batch.items[index]! + if (batch.isolate) queue.unshiftIsolated(item.message, batch.mode, item.localId) + else queue.unshift(item.message, batch.mode, item.localId) + } + throw error + } + } + + if (!stopped && loopAbort.signal.aborted) { + throw loopAbort.signal.reason instanceof Error + ? loopAbort.signal.reason + : new Error('DeepSeek Harness event stream stopped') + } } catch (error) { - crashed = true lifecycle.markCrash(error) - } finally { - if (!crashed) lifecycle.setSessionEndReason('completed') - await lifecycle.cleanupAndExit() + session.sendSessionEvent({ + type: 'error', + message: error instanceof Error ? error.message : 'DeepSeek Harness session failed' + }) + await lifecycle.cleanup() + throw error } } diff --git a/cli/src/modules/common/claudeSessions.test.ts b/cli/src/modules/common/claudeSessions.test.ts new file mode 100644 index 00000000..56c046ab --- /dev/null +++ b/cli/src/modules/common/claudeSessions.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { listLocalClaudeSessionSummaries, listLocalClaudeSessionsWithMessagesByIds } from './claudeSessions' + +const SESSION_ID = '11111111-1111-4111-8111-111111111111' +const CWD = '/tmp/claude-import-project' + +function line(value: Record): string { + return JSON.stringify(value) +} + +describe('local Claude sessions', () => { + let tempDir: string + let previousConfigDir: string | undefined + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'hapi-claude-sessions-')) + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tempDir + mkdirSync(join(tempDir, 'projects', '-tmp-claude-import-project'), { + recursive: true + }) + }) + + afterEach(() => { + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('lists main transcripts, converts visible history, and ignores subagent files', () => { + const projectDir = join(tempDir, 'projects', '-tmp-claude-import-project') + writeFileSync( + join(projectDir, `${SESSION_ID}.jsonl`), + [ + line({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: CWD, + sessionId: SESSION_ID, + type: 'user', + message: { role: 'user', content: 'First prompt' }, + uuid: 'user-1', + timestamp: '2026-08-08T01:00:00.000Z' + }), + line({ + parentUuid: 'user-1', + isSidechain: false, + cwd: CWD, + sessionId: SESSION_ID, + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Answer' }] + }, + uuid: 'assistant-1', + timestamp: '2026-08-08T01:00:01.000Z' + }), + line({ + cwd: CWD, + sessionId: SESSION_ID, + type: 'user', + isMeta: true, + message: { role: 'user', content: 'hidden metadata' }, + uuid: 'meta-1', + timestamp: '2026-08-08T01:00:02.000Z' + }), + line({ + cwd: CWD, + sessionId: SESSION_ID, + type: 'ai-title', + aiTitle: 'Imported work' + }), + line({ + type: 'custom-title', + customTitle: 'Renamed imported work', + sessionId: SESSION_ID + }) + ].join('\n') + ) + + const subagentDir = join(projectDir, SESSION_ID, 'subagents') + mkdirSync(subagentDir, { recursive: true }) + writeFileSync( + join(subagentDir, 'agent-child.jsonl'), + line({ + cwd: CWD, + sessionId: 'agent-child', + type: 'user', + message: { role: 'user', content: 'child prompt' }, + uuid: 'child-user' + }) + ) + + expect(listLocalClaudeSessionSummaries()).toEqual([ + expect.objectContaining({ + id: SESSION_ID, + title: 'Renamed imported work', + lastUserMessage: 'First prompt', + cwd: CWD, + model: 'claude-sonnet-4-5', + messageCount: 2 + }) + ]) + + const full = listLocalClaudeSessionsWithMessagesByIds(new Set([SESSION_ID])) + expect(full).toHaveLength(1) + expect(full[0]?.messages).toEqual([ + expect.objectContaining({ + localId: `claude:${SESSION_ID}:user-1`, + createdAt: Date.parse('2026-08-08T01:00:00.000Z'), + content: expect.objectContaining({ role: 'user' }) + }), + expect.objectContaining({ + localId: `claude:${SESSION_ID}:assistant-1`, + createdAt: Date.parse('2026-08-08T01:00:01.000Z'), + content: expect.objectContaining({ role: 'agent' }) + }) + ]) + }) + + it('returns only requested session transcripts', () => { + const projectDir = join(tempDir, 'projects', '-tmp-claude-import-project') + for (const id of [SESSION_ID, '22222222-2222-4222-8222-222222222222']) { + writeFileSync( + join(projectDir, `${id}.jsonl`), + line({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: CWD, + sessionId: id, + type: 'user', + message: { role: 'user', content: id }, + uuid: `user-${id}`, + timestamp: '2026-08-08T01:00:00.000Z' + }) + ) + } + + const sessions = listLocalClaudeSessionsWithMessagesByIds(new Set([SESSION_ID])) + expect(sessions.map((session) => session.id)).toEqual([SESSION_ID]) + }) + + it('imports only the active branch after a Claude rewind', () => { + const projectDir = join(tempDir, 'projects', '-tmp-claude-import-project') + const transcript = [ + { + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: CWD, + sessionId: SESSION_ID, + type: 'user', + message: { role: 'user', content: 'Shared prompt' }, + uuid: 'user-1' + }, + { + parentUuid: 'user-1', + isSidechain: false, + cwd: CWD, + sessionId: SESSION_ID, + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Shared answer' }, + { type: 'tool_use', id: 'tool-active', name: 'Task', input: {} } + ] + }, + uuid: 'assistant-1' + }, + { + parentUuid: null, + isSidechain: true, + parentToolUseId: 'tool-active', + cwd: CWD, + sessionId: SESSION_ID, + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Active sidechain' }] }, + uuid: 'sidechain-active' + }, + { + parentUuid: 'assistant-1', + isSidechain: false, + cwd: CWD, + sessionId: SESSION_ID, + type: 'attachment', + attachment: { filePath: '/tmp/context.txt' }, + uuid: 'attachment-common' + }, + { + parentUuid: 'attachment-common', + isSidechain: false, + userType: 'external', + cwd: CWD, + sessionId: SESSION_ID, + type: 'user', + message: { role: 'user', content: 'Abandoned prompt' }, + uuid: 'user-old' + }, + { + parentUuid: 'user-old', + isSidechain: false, + cwd: CWD, + sessionId: SESSION_ID, + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Abandoned answer' }, + { type: 'tool_use', id: 'tool-old', name: 'Task', input: {} } + ] + }, + uuid: 'assistant-old' + }, + { + parentUuid: 'assistant-old', + isSidechain: true, + parentToolUseId: 'tool-old', + cwd: CWD, + sessionId: SESSION_ID, + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Abandoned sidechain' }] }, + uuid: 'sidechain-old' + }, + { + parentUuid: 'attachment-common', + isSidechain: false, + userType: 'external', + cwd: CWD, + sessionId: SESSION_ID, + type: 'user', + message: { role: 'user', content: 'Replacement prompt' }, + uuid: 'user-new' + }, + { + parentUuid: 'user-new', + isSidechain: false, + cwd: CWD, + sessionId: SESSION_ID, + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Replacement answer' }] }, + uuid: 'assistant-new' + } + ] + writeFileSync(join(projectDir, `${SESSION_ID}.jsonl`), transcript.map(line).join('\n')) + + const [session] = listLocalClaudeSessionsWithMessagesByIds(new Set([SESSION_ID])) + expect(session?.messages.map((message) => message.localId)).toEqual([ + `claude:${SESSION_ID}:user-1`, + `claude:${SESSION_ID}:assistant-1`, + `claude:${SESSION_ID}:sidechain-active`, + `claude:${SESSION_ID}:user-new`, + `claude:${SESSION_ID}:assistant-new` + ]) + expect(session).toMatchObject({ + lastUserMessage: 'Replacement prompt', + messageCount: 5 + }) + }) + + it('keeps linear history when legacy records have no parent links', () => { + const projectDir = join(tempDir, 'projects', '-tmp-claude-import-project') + writeFileSync( + join(projectDir, `${SESSION_ID}.jsonl`), + [ + line({ + userType: 'external', + cwd: CWD, + sessionId: SESSION_ID, + type: 'user', + message: { role: 'user', content: 'Legacy prompt' }, + uuid: 'legacy-user' + }), + line({ + cwd: CWD, + sessionId: SESSION_ID, + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Legacy answer' }] }, + uuid: 'legacy-assistant' + }) + ].join('\n') + ) + + const [session] = listLocalClaudeSessionsWithMessagesByIds(new Set([SESSION_ID])) + expect(session?.messages.map((message) => message.localId)).toEqual([ + `claude:${SESSION_ID}:legacy-user`, + `claude:${SESSION_ID}:legacy-assistant` + ]) + }) + + it('does not miss cwd when the first transcript record exceeds the old pre-read window', () => { + const projectDir = join(tempDir, 'projects', '-tmp-claude-import-project') + const longPrompt = `Start ${'x'.repeat(70 * 1024)}` + writeFileSync( + join(projectDir, `${SESSION_ID}.jsonl`), + line({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: CWD, + sessionId: SESSION_ID, + type: 'user', + message: { role: 'user', content: longPrompt }, + uuid: 'long-user', + timestamp: '2026-08-08T01:00:00.000Z' + }) + ) + + expect(listLocalClaudeSessionSummaries()).toEqual([ + expect.objectContaining({ + id: SESSION_ID, + cwd: CWD, + messageCount: 1 + }) + ]) + }) +}) diff --git a/cli/src/modules/common/claudeSessions.ts b/cli/src/modules/common/claudeSessions.ts new file mode 100644 index 00000000..c9b35fde --- /dev/null +++ b/cli/src/modules/common/claudeSessions.ts @@ -0,0 +1,323 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import type { + ClaudeImportedMessage, + ClaudeImportedMessageContent, + ClaudeLocalSessionSummary, + ClaudeLocalSessionWithMessages +} from '@hapi/protocol/apiTypes' +import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages' +import { RawJSONLinesSchema, type RawJSONLines } from '@/claude/types' +import { extractRawUserTextContent, isExternalUserMessage } from '@/claude/utils/transcriptMessages' + +const DEFAULT_CLAUDE_SESSION_SCAN_LIMIT = 200 + +type SessionFileCandidate = { + file: string + modifiedAt: number + discoveryIndex: number +} + +type ParsedClaudeSession = { + summary: ClaudeLocalSessionSummary + messages: ClaudeImportedMessage[] +} + +type ClaudeTranscriptRecord = { + event: RawJSONLines | null + uuid: string | null + parentUuid: string | null + isSidechain: boolean + parentToolUseId: string | null + customTitle: string | null +} + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value +} + +function parseTimestamp(value: string | undefined, fallback: number): number { + if (!value) return fallback + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +export function getClaudeProjectsRoot(): string { + const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + return join(configDir, 'projects') +} + +function collectClaudeSessionFiles(): SessionFileCandidate[] { + let projectEntries: import('node:fs').Dirent[] + try { + projectEntries = readdirSync(getClaudeProjectsRoot(), { + withFileTypes: true + }) + } catch { + return [] + } + + const files: string[] = [] + for (const projectEntry of projectEntries) { + if (!projectEntry.isDirectory()) continue + const projectDir = join(getClaudeProjectsRoot(), projectEntry.name) + let sessionEntries: import('node:fs').Dirent[] + try { + sessionEntries = readdirSync(projectDir, { withFileTypes: true }) + } catch { + continue + } + for (const sessionEntry of sessionEntries) { + if (sessionEntry.isFile() && sessionEntry.name.toLowerCase().endsWith('.jsonl')) { + files.push(join(projectDir, sessionEntry.name)) + } + } + } + + return files + .flatMap((file, discoveryIndex) => { + try { + return [{ file, modifiedAt: statSync(file).mtimeMs, discoveryIndex }] + } catch { + return [] + } + }) + .sort((a, b) => b.modifiedAt - a.modifiedAt || a.discoveryIndex - b.discoveryIndex) +} + +function importedUser(text: string): ClaudeImportedMessageContent { + return { + role: 'user', + content: { type: 'text', text }, + meta: { sentFrom: 'cli' } + } +} + +function importedAgent(data: RawJSONLines): ClaudeImportedMessageContent { + return { + role: 'agent', + content: { type: 'output', data }, + meta: { sentFrom: 'cli' } + } +} + +function parseTranscriptRecords(content: string): ClaudeTranscriptRecord[] { + const records: ClaudeTranscriptRecord[] = [] + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + continue + } + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue + const rawRecord = raw as Record + const parsed = RawJSONLinesSchema.safeParse(raw) + records.push({ + event: parsed.success ? parsed.data : null, + uuid: typeof rawRecord.uuid === 'string' ? rawRecord.uuid : null, + parentUuid: typeof rawRecord.parentUuid === 'string' ? rawRecord.parentUuid : null, + isSidechain: rawRecord.isSidechain === true, + parentToolUseId: typeof rawRecord.parentToolUseId === 'string' ? rawRecord.parentToolUseId : null, + customTitle: rawRecord.type === 'custom-title' && typeof rawRecord.customTitle === 'string' + ? rawRecord.customTitle + : null + }) + } + return records +} + +function isImportableConversationRecord(record: ClaudeTranscriptRecord): record is ClaudeTranscriptRecord & { event: RawJSONLines; uuid: string } { + const event = record.event + return Boolean( + event && + record.uuid && + !event.isMeta && + !event.isCompactSummary && + isClaudeChatVisibleMessage(event) + ) +} + +function activeClaudeRecordIds(records: ClaudeTranscriptRecord[]): Set | null { + const topology = new Map() + for (const record of records) { + if (record.uuid) topology.set(record.uuid, record) + } + let leaf: ClaudeTranscriptRecord | null = null + for (let index = records.length - 1; index >= 0; index -= 1) { + const record = records[index]! + if (!record.isSidechain && isImportableConversationRecord(record)) { + leaf = record + break + } + } + if (!leaf?.uuid) return null + + const activeMainIds = new Set() + const visited = new Set() + let currentUuid: string | null = leaf.uuid + let followedKnownParent = false + while (currentUuid && !visited.has(currentUuid)) { + visited.add(currentUuid) + activeMainIds.add(currentUuid) + const current = topology.get(currentUuid) + const parentUuid = current?.parentUuid ?? null + if (parentUuid && topology.has(parentUuid)) followedKnownParent = true + currentUuid = parentUuid + } + if (!followedKnownParent) return null + + const activeToolUseIds = new Set() + for (const record of records) { + if (!record.uuid || !activeMainIds.has(record.uuid) || record.event?.type !== 'assistant') continue + const content = record.event.message?.content + if (!Array.isArray(content)) continue + for (const block of content) { + if (block === null || typeof block !== 'object' || Array.isArray(block)) continue + const toolUse = block as Record + if (toolUse.type === 'tool_use' && typeof toolUse.id === 'string') activeToolUseIds.add(toolUse.id) + } + } + + const activeIds = new Set(activeMainIds) + for (const record of records) { + if (!record.uuid || !record.isSidechain || !isImportableConversationRecord(record)) continue + const sidechainVisited = new Set() + let sidechainUuid: string | null = record.uuid + while (sidechainUuid && !sidechainVisited.has(sidechainUuid)) { + if (activeMainIds.has(sidechainUuid)) { + activeIds.add(record.uuid) + break + } + sidechainVisited.add(sidechainUuid) + const current = topology.get(sidechainUuid) + if (current && !current.isSidechain) break + if (current?.parentToolUseId && activeToolUseIds.has(current.parentToolUseId)) { + activeIds.add(record.uuid) + break + } + sidechainUuid = current?.parentUuid ?? null + } + } + return activeIds +} + +function parseClaudeLocalSession(filePath: string, knownModifiedAt?: number): ParsedClaudeSession | null { + let content: string + let modifiedAt: number + try { + content = readFileSync(filePath, 'utf-8') + modifiedAt = knownModifiedAt ?? statSync(filePath).mtimeMs + } catch { + return null + } + + const sessionId = basename(filePath, '.jsonl') + if (!sessionId) return null + + let cwd: string | null = null + let customTitle: string | null = null + let aiTitle: string | null = null + let summary: string | null = null + let firstUserMessage: string | null = null + let lastUserMessage: string | null = null + let model: string | null = null + const messages: ClaudeImportedMessage[] = [] + const records = parseTranscriptRecords(content) + const activeRecordIds = activeClaudeRecordIds(records) + + for (const record of records) { + if (record.customTitle !== null) { + customTitle = record.customTitle.trim() || customTitle + continue + } + const event = record.event + if (!event) continue + + cwd ??= event.cwd?.trim() || null + if (event.type === 'ai-title') { + aiTitle = event.aiTitle.trim() || aiTitle + continue + } + if (event.type === 'summary') { + summary = event.summary.trim() || summary + continue + } + if (!isImportableConversationRecord(record)) continue + + const uuid = record.uuid + if (activeRecordIds && !activeRecordIds.has(uuid)) continue + const createdAt = parseTimestamp(event.timestamp, modifiedAt) + if (isExternalUserMessage(event)) { + const text = extractRawUserTextContent(event.message.content)?.trim() + if (!text) continue + firstUserMessage ??= text + lastUserMessage = text + messages.push({ + localId: `claude:${sessionId}:${uuid}`, + createdAt, + content: importedUser(text) + }) + continue + } + + if (event.type === 'assistant' && event.message?.model) model = event.message.model + messages.push({ + localId: `claude:${sessionId}:${uuid}`, + createdAt, + content: importedAgent(event) + }) + } + + if (!cwd || messages.length === 0) return null + const displayTitle = + customTitle ?? aiTitle ?? (firstUserMessage ? truncateText(firstUserMessage, 80) : null) ?? summary ?? basename(cwd) ?? sessionId.slice(0, 8) + + return { + summary: { + id: sessionId, + title: displayTitle, + lastUserMessage: lastUserMessage ? truncateText(lastUserMessage, 140) : null, + cwd, + file: filePath, + modifiedAt, + model, + messageCount: messages.length + }, + messages + } +} + +export function listLocalClaudeSessionSummaries(limit = DEFAULT_CLAUDE_SESSION_SCAN_LIMIT): ClaudeLocalSessionSummary[] { + if (limit <= 0) return [] + const summaries: ClaudeLocalSessionSummary[] = [] + const seenIds = new Set() + for (const candidate of collectClaudeSessionFiles()) { + const sessionId = basename(candidate.file, '.jsonl') + if (!sessionId || seenIds.has(sessionId)) continue + const parsed = parseClaudeLocalSession(candidate.file, candidate.modifiedAt) + if (!parsed) continue + seenIds.add(sessionId) + summaries.push(parsed.summary) + if (summaries.length >= limit) break + } + return summaries +} + +export function listLocalClaudeSessionsWithMessagesByIds(ids: Set): ClaudeLocalSessionWithMessages[] { + if (ids.size === 0) return [] + const unresolved = new Set(ids) + const sessions: ClaudeLocalSessionWithMessages[] = [] + for (const candidate of collectClaudeSessionFiles()) { + const sessionId = basename(candidate.file, '.jsonl') + if (!unresolved.has(sessionId)) continue + const parsed = parseClaudeLocalSession(candidate.file, candidate.modifiedAt) + if (!parsed) continue + unresolved.delete(sessionId) + sessions.push({ ...parsed.summary, messages: parsed.messages }) + if (unresolved.size === 0) break + } + return sessions +} diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 97c5c46d..069b2c11 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -24,9 +24,10 @@ import { import { OpencodePermissionHandler } from './utils/permissionHandler'; import { getOpencodeNativeToolInstruction, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt'; import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; +import { fetchOpenCodeReasoningEffortState, type OpenCodeReasoningEffortState } from './utils/opencodeVariants'; +import type { AgentSessionConfigOptionDescriptor } from '@/agent/types'; type OpencodeRemoteLauncherOptions = { - onModelRollback?: (model: string | null) => void; onReasoningEffortRollback?: (effort: string | null) => void; // Called with `true` once the ACP backend + internal HTTP baseUrl are // ready (so /compact can actually run) and with `false` whenever this @@ -129,6 +130,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { private defaultBackendModel: string | null = null; private currentBackendEffort: string | null = null; private defaultBackendEffort: string | null = null; + private nativeReasoningEffortState: OpenCodeReasoningEffortState | null = null; private setModelSupported: boolean | undefined = undefined; private setEffortSupported: boolean | undefined = undefined; private activeAcpSessionId: string | null = null; @@ -251,33 +253,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId); this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null; this.defaultBackendEffort = this.currentBackendEffort; - - // The CLI may have been launched with an explicit --model that differs - // from the ACP session's own default. Apply it eagerly right here so - // the new model's thought_level config options are captured (via - // setModel's set_config_option round-trip) *before* the web UI's first - // effort-options poll — otherwise a variant-capable startup model looks - // unsupported until after the first turn. On failure just warn: the - // first batch's existing inline switch path retries the same model. - const requestedStartupModel = this.session.getModel?.(); - if ( - !this.shouldExit - && typeof requestedStartupModel === 'string' - && requestedStartupModel.length > 0 - && requestedStartupModel !== this.defaultBackendModel - && typeof backend.setModel === 'function' - ) { - try { - await backend.setModel(acpSessionId, requestedStartupModel, { flavor: 'opencode' }); - this.currentBackendModel = requestedStartupModel; - // The lookup above ran before the switch — re-query so the - // seeded effort reflects the eagerly applied model. - const refreshedThoughtLevel = backend.getThoughtLevelConfigOption?.(acpSessionId); - this.currentBackendEffort = refreshedThoughtLevel?.currentValue ?? null; - this.defaultBackendEffort = this.currentBackendEffort; - } catch (error) { - logger.warn('[opencode-remote] Eager startup model application failed; first batch will retry inline', error); - } + if (!thoughtLevelOption) { + await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel); } // Let the caller (runOpencode.ts) know native /compact can actually @@ -329,7 +306,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { const targetModelId = requestedModel === null ? this.defaultBackendModel : requestedModel ?? currentModelId; - if (!effortOption) { + if (effortOption) { + return { + success: true, + options: effortOption.options, + currentValue: effortOption.currentValue ?? null, + currentModelId, + targetModelId + }; + } + await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel); + const nativeOption = this.nativeReasoningEffortState?.option; + if (!nativeOption) { return { success: false, error: 'OpenCode reasoning effort options are not available', @@ -339,11 +327,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { } return { success: true, - options: effortOption.options, - currentValue: effortOption.currentValue ?? null, - // Lets the web client detect "options still belong to the - // previous model" while a requested switch has not been - // applied by the backend yet. + options: nativeOption.options, + currentValue: this.currentBackendEffort ?? nativeOption.currentValue ?? null, currentModelId, targetModelId }; @@ -432,22 +417,13 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.currentBackendModel = requestedModel; } else if (requestedModel && requestedModel !== this.currentBackendModel) { if (!backend.setModel || this.setModelSupported === false) { - this.rollbackModel(batch, this.currentBackendModel); + batch.mode.model = this.currentBackendModel ?? undefined; } else { logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${requestedModel}`); try { await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' }); this.currentBackendModel = requestedModel; this.setModelSupported = true; - // set_config_option("model") also switches the backend's - // effort currentValue — refresh both cached efforts so - // a subsequent request equal to the stale value still - // performs the round-trip instead of being skipped, - // and an unset effort falls back to the *new* model's - // default rather than reapplying the old model's. - const refreshedInlineEffort = backend.getThoughtLevelConfigOption?.(acpSessionId); - this.currentBackendEffort = refreshedInlineEffort?.currentValue ?? null; - this.defaultBackendEffort = this.currentBackendEffort; // Reflect the resolved model back into the batch so // downstream display logic sees the concrete id rather // than a `null` placeholder. @@ -469,20 +445,29 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { message: `Failed to switch model to ${requestedModel}. Continuing with ${this.currentBackendModel ?? '(default)'}.` }); } - this.rollbackModel(batch, this.currentBackendModel); + batch.mode.model = this.currentBackendModel ?? undefined; } } + if (requestedModel === this.currentBackendModel) { + await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel); + } } const requestedEffort = batch.mode.modelReasoningEffort ?? this.defaultBackendEffort; if (requestedEffort && requestedEffort !== this.currentBackendEffort) { const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId); - if (!backend.setConfigOption || !thoughtLevelOption || this.setEffortSupported === false) { + const nativeEffortOption = this.nativeReasoningEffortState + && this.nativeReasoningEffortState.modelId === this.currentBackendModel + ? this.nativeReasoningEffortState.option + : undefined; + const effortOption: AgentSessionConfigOptionDescriptor | undefined = thoughtLevelOption ?? nativeEffortOption; + const nativeVariantSupported = Boolean(nativeEffortOption && backend.promptWithVariant); + if (!effortOption || (!backend.setConfigOption && !nativeVariantSupported) || this.setEffortSupported === false) { this.rollbackReasoningEffort(batch, this.currentBackendEffort); } else { const resolvedEffort = resolveThoughtLevelEffort( requestedEffort, - thoughtLevelOption, + effortOption, this.currentBackendEffort ?? this.defaultBackendEffort ); if (!resolvedEffort || resolvedEffort === this.currentBackendEffort) { @@ -495,8 +480,17 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { } else { logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${resolvedEffort}`); try { - await backend.setConfigOption(acpSessionId, thoughtLevelOption.id, resolvedEffort); - this.currentBackendEffort = resolvedEffort; + if (nativeVariantSupported) { + this.currentBackendEffort = resolvedEffort; + this.nativeReasoningEffortState = { + ...this.nativeReasoningEffortState!, + currentValue: resolvedEffort, + option: { ...nativeEffortOption!, currentValue: resolvedEffort } + }; + } else { + await backend.setConfigOption!(acpSessionId, effortOption.id, resolvedEffort); + this.currentBackendEffort = resolvedEffort; + } this.setEffortSupported = true; if (requestedEffort !== resolvedEffort) { this.rollbackReasoningEffort(batch, resolvedEffort); @@ -663,9 +657,26 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { session.onThinkingChange(true); try { - await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { - this.handleAgentMessage(message); - }); + const nativeVariant = this.nativeReasoningEffortState + && this.nativeReasoningEffortState.modelId === this.currentBackendModel + ? this.currentBackendEffort + : null; + const nativeModel = splitProviderModel(this.currentBackendModel); + if (nativeVariant && nativeModel && backend.promptWithVariant && this.baseUrl) { + await backend.promptWithVariant(acpSessionId, promptContent, (message: AgentMessage) => { + this.handleAgentMessage(message); + }, { + baseUrl: this.baseUrl, + directory: session.path, + providerId: nativeModel.providerId, + modelId: nativeModel.modelId, + variant: nativeVariant + }); + } else { + await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { + this.handleAgentMessage(message); + }); + } void backend.refreshSessionInfo(acpSessionId, session.path); } catch (error) { logger.warn('[opencode-remote] prompt failed', error); @@ -832,6 +843,35 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.surfaceAgentError(formatOpencodePromptError(error)); } + private async refreshNativeReasoningEffortState( + acpSessionId: string, + modelId: string | null + ): Promise { + const baseUrl = this.baseUrl; + if (!baseUrl || !modelId) { + this.nativeReasoningEffortState = null; + return; + } + + const state = await fetchOpenCodeReasoningEffortState({ + baseUrl, + directory: this.session.path, + sessionId: acpSessionId, + modelId + }); + this.nativeReasoningEffortState = state; + if (!state) return; + + // The native session is authoritative. A variant may have been + // changed in OpenCode itself between HAPI polls, so refresh both the + // current and launch-time fallback values from the live session. + this.currentBackendEffort = state.currentValue ?? state.option.options[0]?.value ?? null; + this.defaultBackendEffort = this.currentBackendEffort; + // A model switch can move from an ACP-only effort option to native + // variants (or vice versa); do not retain a previous capability probe. + this.setEffortSupported = undefined; + } + private surfaceAgentError(message: string): void { this.session.sendAgentMessage({ type: 'error', message }); this.messageBuffer.addMessage(message, 'status'); @@ -859,13 +899,6 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.options.onReasoningEffortRollback?.(effort); } - private rollbackModel(batch: { mode: OpencodeMode }, model: string | null): void { - batch.mode.model = model ?? undefined; - this.session.setModel(model); - this.session.pushKeepAlive(); - this.options.onModelRollback?.(model); - } - /** * Executes the /compact operation for a queued `operation:'compact'` * batch. Reached only through the main dequeue loop (so it never runs diff --git a/cli/src/opencode/utils/opencodeVariants.test.ts b/cli/src/opencode/utils/opencodeVariants.test.ts new file mode 100644 index 00000000..bdcd1c6f --- /dev/null +++ b/cli/src/opencode/utils/opencodeVariants.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fetchOpenCodeReasoningEffortState } from './opencodeVariants'; + +function response(payload: unknown, ok = true): Response { + return new Response(JSON.stringify(payload), { + status: ok ? 200 : 404, + headers: { 'content-type': 'application/json' } + }); +} + +describe('fetchOpenCodeReasoningEffortState', () => { + it('discovers native model variants and the live selected value', async () => { + const fetchImpl = vi.fn(async (url: string) => { + if (url.includes('/session/')) { + return response({ + model: { + providerID: 'opencode-go', + id: 'deepseek-v4.1-flash', + variant: 'max' + } + }); + } + return response({ + all: [{ + id: 'opencode-go', + models: { + 'deepseek-v4.1-flash': { + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + max: { reasoningEffort: 'max' } + } + } + } + }] + }); + }); + + const state = await fetchOpenCodeReasoningEffortState({ + baseUrl: 'http://127.0.0.1:1234', + directory: '/workspace', + sessionId: 'session-1', + fetchImpl + }); + + expect(state).toMatchObject({ + modelId: 'opencode-go/deepseek-v4.1-flash', + currentValue: 'max', + option: { + category: 'thought_level', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + { value: 'max', name: 'Max' } + ] + } + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('returns null when the selected model has no variants', async () => { + const fetchImpl = vi.fn(async (url: string) => url.includes('/session/') + ? response({ model: { providerID: 'opencode-go', id: 'big-pickle' } }) + : response({ all: [{ id: 'opencode-go', models: { 'big-pickle': { variants: {} } } }] })); + + await expect(fetchOpenCodeReasoningEffortState({ + baseUrl: 'http://127.0.0.1:1234', + directory: '/workspace', + sessionId: 'session-1', + fetchImpl + })).resolves.toBeNull(); + }); +}); + diff --git a/cli/src/opencode/utils/opencodeVariants.ts b/cli/src/opencode/utils/opencodeVariants.ts new file mode 100644 index 00000000..34c61bc1 --- /dev/null +++ b/cli/src/opencode/utils/opencodeVariants.ts @@ -0,0 +1,115 @@ +import type { AgentSessionConfigOptionDescriptor } from '@/agent/types'; + +/** Minimal fetch signature used by the OpenCode variant discovery tests. */ +export type OpenCodeVariantFetch = (url: string, init?: RequestInit) => Promise; + +export type OpenCodeReasoningEffortState = { + modelId: string; + currentValue: string | null; + option: AgentSessionConfigOptionDescriptor; +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function splitModelId(value: string): { providerId: string; modelId: string } | null { + const separator = value.indexOf('/'); + if (separator <= 0 || separator === value.length - 1) return null; + return { + providerId: value.slice(0, separator), + modelId: value.slice(separator + 1) + }; +} + +function displayName(value: string): string { + return value + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +function getProviders(payload: unknown): JsonRecord[] { + if (!isRecord(payload)) return []; + const providers = Array.isArray(payload.all) + ? payload.all + : Array.isArray(payload.providers) + ? payload.providers + : []; + return providers.filter(isRecord); +} + +/** + * Reads OpenCode's native model variants. + * + * OpenCode exposes DeepSeek reasoning levels as model `variants` in its HTTP + * API. They are intentionally not inferred from the model name: providers can + * add/remove levels independently, and the ACP configOptions response does not + * currently include this information. + */ +export async function fetchOpenCodeReasoningEffortState(options: { + baseUrl: string; + directory: string; + sessionId: string; + modelId?: string | null; + fetchImpl?: OpenCodeVariantFetch; +}): Promise { + const fetchFn = options.fetchImpl ?? (fetch as OpenCodeVariantFetch); + const query = `?directory=${encodeURIComponent(options.directory)}`; + + try { + const sessionResponse = await fetchFn( + `${options.baseUrl}/session/${encodeURIComponent(options.sessionId)}${query}`, + { method: 'GET' } + ); + if (!sessionResponse.ok) return null; + const sessionPayload: unknown = await sessionResponse.json().catch(() => null); + const sessionModel = isRecord(sessionPayload) && isRecord(sessionPayload.model) + ? sessionPayload.model + : null; + + const providerId = asString(sessionModel?.providerID) + ?? (options.modelId ? splitModelId(options.modelId)?.providerId : null); + const modelId = asString(sessionModel?.id) + ?? (options.modelId ? splitModelId(options.modelId)?.modelId : null); + if (!providerId || !modelId) return null; + + const providersResponse = await fetchFn(`${options.baseUrl}/provider${query}`, { method: 'GET' }); + if (!providersResponse.ok) return null; + const providersPayload: unknown = await providersResponse.json().catch(() => null); + const provider = getProviders(providersPayload).find((entry) => entry.id === providerId); + const models = provider && isRecord(provider.models) ? provider.models : null; + const model = models && isRecord(models[modelId]) ? models[modelId] : null; + const variants = model && isRecord(model.variants) ? model.variants : null; + if (!variants) return null; + + const optionsList = Object.keys(variants) + .filter((value) => value.trim().length > 0) + .map((value) => ({ value, name: displayName(value) })); + if (optionsList.length === 0) return null; + + const currentVariant = asString(sessionModel?.variant); + return { + modelId: `${providerId}/${modelId}`, + currentValue: currentVariant && optionsList.some((entry) => entry.value === currentVariant) + ? currentVariant + : null, + option: { + id: `opencode/variant/${providerId}/${modelId}`, + category: 'thought_level', + currentValue: currentVariant ?? undefined, + options: optionsList + } + }; + } catch { + // Discovery is supplementary. An older OpenCode build may not expose + // /provider or /session; the ACP thought-level path can still be used. + return null; + } +} + diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 4630d29c..f55561e0 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -162,6 +162,32 @@ describe('buildCliArgs', () => { expect(args).not.toContain('--hapi-session-id') }) + it('passes DSH native and HAPI session ids separately on resume', () => { + const args = buildCliArgs('dsh', { + directory: '/tmp', + resumeSessionId: 'dsh-session-1', + existingSessionId: 'hapi-session-1', + model: 'deepseek-v4-pro', + modelReasoningEffort: 'max' + }) + + expect(args).toEqual([ + 'dsh', + '--resume', + 'dsh-session-1', + '--hapi-starting-mode', + 'remote', + '--started-by', + 'runner', + '--existing-session-id', + 'hapi-session-1', + '--model', + 'deepseek-v4-pro', + '--model-reasoning-effort', + 'max' + ]) + }) + it('passes --existing-session-id for cursor resume when sessionId is set (#991)', () => { const args = buildCliArgs('cursor', { directory: '/tmp', diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 4d0186fe..3a0f582f 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -28,11 +28,8 @@ import { join } from 'path'; import { buildMachineMetadata } from '@/agent/sessionFactory'; import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity'; -import { readRuntimes, runtimeMayBeAlive, runtimeAuthHash } from '@/codex/shared/registry'; import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm'; import { isLinkedGitWorktree } from '@/utils/isLinkedGitWorktree'; -import { agentUnavailableMessage, getAgentAvailability } from '@/agent/agentAvailability'; -import { copyCodexConfigFile, resolveCodexHome } from '@/codex/utils/codexHome'; /** * Deduplicates a preallocated HAPI-row spawn only while its child is alive. @@ -415,9 +412,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): }; // Helper functions - const getCurrentChildren = () => Array.from(pidToTrackedSession.values()).flatMap(session => session.sharedSessions - ? Object.entries(session.sharedSessions).map(([happySessionId, metadata]) => ({ ...session, happySessionId, happySessionMetadataFromLocalWebhook: metadata })) - : [session]); + const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); // Handle webhook from HAPI session reporting itself const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => { @@ -435,18 +430,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Check if we already have this PID (runner-spawned) const existingSession = pidToTrackedSession.get(pid); - if (existingSession && sessionMetadata.capabilities?.concurrentClients) { - existingSession.sharedSessions ??= {}; - if (sessionMetadata.lifecycleState === 'archived') { - delete existingSession.sharedSessions[sessionId]; - return; - } - existingSession.sharedSessions[sessionId] = sessionMetadata; - invalidateVerifiedExit(sessionId); - // Native /new or /fork cannot replace the primary spawn confirmation. - if (existingSession.happySessionId && existingSession.happySessionId !== sessionId) return; - } - if (existingSession && existingSession.startedBy === 'runner') { // Update runner-spawned session with reported data invalidateVerifiedExit(sessionId); @@ -481,11 +464,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // anything claiming `'runner'` here must be the second case and // should be ignored + terminated instead of silently promoted. if (sessionMetadata.startedBy === 'runner') { - // A shared root can report /new after a Runner restart. Unknown is - // not proof of an orphan: never kill its sibling roots. Known spawn - // timeouts already terminate their ChildProcess tree at the source. - // No registry scan/adoption lifecycle is needed for live attachment. - if (sessionMetadata.capabilities?.concurrentClients) return; logger.debug( `[RUNNER RUN] Ignoring late webhook from orphaned runner-spawned PID ${pid} (session ${sessionId}). Terminating child.` ); @@ -502,7 +480,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // New session started externally (terminal) const trackedSession: TrackedSession = { - ...(sessionMetadata.capabilities?.concurrentClients ? { sharedSessions: { [sessionId]: sessionMetadata } } : {}), startedBy: 'hapi directly - likely by user from terminal', happySessionId: sessionId, happySessionMetadataFromLocalWebhook: sessionMetadata, @@ -522,27 +499,8 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; const agent = options.agent ?? 'claude'; - const availability = getAgentAvailability(agent); - if (!availability.available) { - const errorMessage = agentUnavailableMessage(availability); - logger.debug(`[RUNNER RUN] Agent preflight failed: ${errorMessage}`); - reportSpawnOutcomeToHub?.({ - type: 'error', - details: { message: errorMessage } - }); - return { - type: 'error', - errorMessage, - code: 'agent_unavailable', - agent - }; - } - if (options.validateDirectory && !(await options.validateDirectory(directory))) { - return { - type: 'error', - errorMessage: 'Directory is outside this machine\'s workspace roots', - code: 'outside_workspace_roots' - }; + if (agent === 'gemini') { + throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.'); } const yolo = options.yolo === true; const sessionType = options.sessionType ?? 'simple'; @@ -551,21 +509,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): let spawnDirectory = directory; let worktreeInfo: WorktreeInfo | null = null; let happyProcess: ReturnType | null = null; - let copiedCodexConfigPath: string | null = null; - - const cleanupCopiedCodexConfig = async (reason: string): Promise => { - const configPath = copiedCodexConfigPath; - copiedCodexConfigPath = null; - if (!configPath) { - return; - } - try { - await fs.rm(configPath, { force: true }); - logger.debug(`[RUNNER RUN] Removed temporary Codex config after ${reason}`); - } catch (error) { - logger.debug(`[RUNNER RUN] Failed to remove temporary Codex config after ${reason}`, error); - } - }; if (sessionType === 'simple') { const validation = await validateWorkspaceDirectory(directory, { @@ -604,17 +547,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } } - // Re-check after mkdir/access so a newly materialized path or concurrent - // symlink swap cannot escape the roots checked by the machine RPC layer. - if (options.validateDirectory && !(await options.validateDirectory(directory))) { - logger.debug(`[RUNNER RUN] Workspace directory escaped roots during validation: ${directory}`); - return { - type: 'error', - errorMessage: 'Directory is outside this machine\'s workspace roots', - code: 'outside_workspace_roots' - }; - } - if (sessionType === 'worktree') { // Cursor Agent has native `--worktree` under ~/.cursor/worktrees/. Prefer that // over HAPI's sibling-directory worktree so Cursor sandbox/skills see the same layout. @@ -684,9 +616,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Create a temporary directory for Codex const codexHomeDir = await fs.mkdtemp(join(os.tmpdir(), 'hapi-codex-')); - // Preserve user MCP/config settings while keeping token auth isolated. - copiedCodexConfigPath = await copyCodexConfigFile(resolveCodexHome(), codexHomeDir); - // Write the token to the temporary directory await fs.writeFile(join(codexHomeDir, 'auth.json'), options.token); @@ -742,9 +671,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): ...extraEnv } }); - happyProcess.once('exit', () => { - void cleanupCopiedCodexConfig('child-exit'); - }); happyProcess.stderr?.on('data', (data) => { stderrTail = appendTail(stderrTail, data); @@ -771,7 +697,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): message: errorMessage } }); - await cleanupCopiedCodexConfig('no-pid'); await maybeCleanupWorktree('no-pid'); return { type: 'error', @@ -897,11 +822,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // (the actual claude/codex agent) are also reaped, and that // SIGTERM → SIGKILL escalation kicks in if needed. if (happyProcess) { - void killProcessByChildProcess(happyProcess).finally(() => { - void cleanupCopiedCodexConfig('webhook-timeout'); - }); - } else { - void cleanupCopiedCodexConfig('webhook-timeout'); + void killProcessByChildProcess(happyProcess); } // If this was a worktree session, the worktree can only be @@ -958,7 +879,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.debug('[RUNNER RUN] Failed to spawn session:', error); - await cleanupCopiedCodexConfig('exception'); await maybeCleanupWorktree('exception'); reportSpawnOutcomeToHub?.({ type: 'error', @@ -989,22 +909,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const stopSession = async (sessionId: string): Promise<'stopped' | 'already_gone' | 'still_alive'> => { logger.debug(`[RUNNER RUN] Attempting to stop session ${sessionId}`); - const { findRuntime } = await import('@/codex/shared/registry'); - const sharedRuntime = await findRuntime(sessionId); - if (sharedRuntime) { - try { - const { runtimeControl } = await import('@/codex/shared/frontend'); - await runtimeControl(sharedRuntime, 'hapi/stopSession', sessionId); - const tracked = pidToTrackedSession.get(sharedRuntime.pid); - if (tracked?.sharedSessions) delete tracked.sharedSessions[sessionId]; - return 'stopped'; - } catch { return 'still_alive'; } - } - if ((await readRuntimes()).some(runtime => runtime.hub === configuration.apiUrl && runtime.authHash === runtimeAuthHash() - && runtime.sessions[sessionId]?.active && runtimeMayBeAlive(runtime))) return 'still_alive'; - // Missing registry is not permission to kill siblings in a live execution. - if ([...pidToTrackedSession.values()].some(session => session.sharedSessions?.[sessionId])) return 'still_alive'; - // Try to find by sessionId first for (const [pid, session] of pidToTrackedSession.entries()) { if (session.happySessionId === sessionId || @@ -1118,7 +1022,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Handle child process exit const onChildExited = (pid: number) => { const session = pidToTrackedSession.get(pid); - for (const id of Object.keys(session?.sharedSessions ?? {})) rememberVerifiedExit(id); const requestedSessionId = session?.requestedHappySessionId ?? pidToRequestedSessionId.get(pid); if (requestedSessionId) rememberVerifiedExit(requestedSessionId); const confirmedSessionId = session?.happySessionId ?? pidToConfirmedSessionId.get(pid); @@ -1163,8 +1066,8 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // but in compiled binary mode (`bun build --compile`) the raw argv shape is // `[hapi, runner, start-sync, ...]` so slice(2) produced `['start-sync', ...]`. // The replacement then spawned `hapi start-sync ...`, which `resolveCommand` - // now rejects as an unknown top-level command (previously it fell back to - // Claude). `getCliArgs()` strips runtime + entrypoint + // treats as an unknown top-level command - falling back to Claude instead + // of starting the runner. `getCliArgs()` strips runtime + entrypoint // correctly in all execution modes. // // Defensive guard: only replay the captured argv when it actually starts @@ -1183,7 +1086,6 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Write initial runner state (no lock needed for state file) const fileState: RunnerLocallyPersistedState = { - sharedCodexRuntime: true, pid: process.pid, httpPort: controlPort, startTime: new Date().toLocaleString(), @@ -1257,7 +1159,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // regardless of the verbose/quiet logger setting. console.log(''); console.log('Hapi runner started.'); - console.log(` Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set — browsing is limited to home)'}`); + console.log(` Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set — browse disabled; pass --workspace-root to enable)'}`); console.log(` Hub URL: ${configuration.apiUrl}`); console.log(` Machine ID: ${machine.id}`); console.log(` Control port: ${controlPort}`); @@ -1493,8 +1395,19 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Heartbeat try { const updatedState: RunnerLocallyPersistedState = { - ...fileState, - lastHeartbeat: new Date().toLocaleString() + pid: process.pid, + httpPort: controlPort, + startTime: fileState.startTime, + startedWithCliVersion: packageJson.version, + startedWithCliMtimeMs, + startedWithApiUrl: fileState.startedWithApiUrl, + startedWithMachineId: fileState.startedWithMachineId, + startedWithCliApiTokenHash: fileState.startedWithCliApiTokenHash, + startedWithExtraHeadersHash: fileState.startedWithExtraHeadersHash, + startedWithArgv, + startedWithVersionHandoffDisabled, + lastHeartbeat: new Date().toLocaleString(), + runnerLogPath: fileState.runnerLogPath }; writeRunnerState(updatedState); if (process.env.DEBUG) { @@ -1560,6 +1473,8 @@ export function buildCliArgs( ? 'codex' : agent === 'cursor' ? 'cursor' + : agent === 'dsh' + ? 'dsh' : agent === 'grok' ? 'grok' : agent === 'kimi' @@ -1568,13 +1483,11 @@ export function buildCliArgs( ? 'copilot' : agent === 'opencode' ? 'opencode' - : agent === 'dsh' - ? 'dsh' - : agent === 'pi' - ? 'pi' - : agent === 'agy' - ? 'agy' - : 'claude'; + : agent === 'pi' + ? 'pi' + : agent === 'agy' + ? 'agy' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { @@ -1595,15 +1508,12 @@ export function buildCliArgs( args.push('--fork-session'); } const startingMode = options.startingMode || 'remote'; - // Codex shares one engine; Runner owns the wrapper, not a remote mode. - if (agent !== 'codex') args.push('--hapi-starting-mode', startingMode); - args.push('--started-by', 'runner'); - // Codex, Cursor ACP, OpenCode, Pi native resume, and Claude message-level + args.push('--hapi-starting-mode', startingMode, '--started-by', 'runner'); + // Codex, Cursor ACP, DSH, OpenCode, Pi native resume, and Claude message-level // forks reuse the original HAPI row via --existing-session-id. - if (agent === 'codex' || agent === 'cursor' || agent === 'pi' + if (agent === 'codex' || agent === 'cursor' || agent === 'dsh' || agent === 'pi' || agent === 'opencode' || agent === 'agy' - || agent === 'dsh' || (agentCommand === 'claude' && options.forkSession)) { const existingSessionId = options.existingSessionId ?? options.sessionId; if (existingSessionId) { @@ -1623,7 +1533,7 @@ export function buildCliArgs( if (options.effort && (agent === 'claude' || agent === 'grok' || agent === 'pi' || agent === 'agy')) { args.push('--effort', options.effort); } - if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) { + if (options.modelReasoningEffort && (agent === 'codex' || agent === 'dsh' || agent === 'opencode')) { args.push('--model-reasoning-effort', options.modelReasoningEffort); } if (options.serviceTier && agent === 'codex') { @@ -1637,7 +1547,7 @@ export function buildCliArgs( } // Pi RPC mode has no permission switching; never pass these flags to it // (the Pi parser rejects --permission-mode and ignores --yolo). - if (agent !== 'pi' && agent !== 'dsh') { + if (agent !== 'pi') { if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { args.push('--permission-mode', options.permissionMode); } else if (yolo) { diff --git a/docs/guide/agents.md b/docs/guide/agents.md index 79fccd3d..78fc89d9 100644 --- a/docs/guide/agents.md +++ b/docs/guide/agents.md @@ -2,24 +2,18 @@ HAPI is a wrapper around AI coding agents. One CLI (`hapi `) starts any supported agent locally and exposes the same session for remote control from the web app, PWA, and Telegram — with permission prompts, message queueing, and seamless handoff between terminal and phone. -Run `hapi` without arguments to choose an agent interactively. The picker shows -all supported agents alphabetically by command name; missing or misconfigured -agents are disabled with a reason. Scripts must use `hapi [options]`. -`hapi --help` shows HAPI's own help. Options after an agent name belong to that -agent's integration; their supported syntax varies by agent. - ## Support matrix | Agent | Command | Integration | Local | Remote | Permission modes | Resume | |-------|---------|-------------|:-----:|:------:|------------------|:------:| -| Claude Code | `hapi claude` | Terminal wrapper (local) + Claude Agent SDK (remote) | ✓ | ✓ | `default` `acceptEdits` `auto` `bypassPermissions` `plan` | ✓ | -| Codex | `hapi codex` | Native terminal + `codex app-server` (Codex 0.154.0+) | ✓ | ✓ | `default` `read-only` `yolo` (+ `plan` collaboration mode) | ✓ | +| Claude Code | `hapi` / `hapi claude` | Terminal wrapper (local) + Claude Agent SDK (remote) | ✓ | ✓ | `default` `acceptEdits` `auto` `bypassPermissions` `plan` | ✓ | +| Codex | `hapi codex` | TUI wrapper (local) + `codex app-server` JSON-RPC (remote) | ✓ | ✓ | `default` `read-only` `safe-yolo` `yolo` (+ `plan` collaboration mode) | ✓ | | Cursor Agent | `hapi cursor` | ACP (`agent acp`); legacy stream-json resume | ✓ | ✓ | `default` `plan` `ask` `debug` `autoReview` `yolo` | ✓ | +| DeepSeek Harness | `hapi dsh` | DSH Web HTTP/WebSocket RPC | — | ✓ | `default` `read-only` `workspace-write` `danger-full-access` | ✓ | | Grok Build | `hapi grok` | ACP (`grok agent stdio`) | ✓ | ✓ | `default` `auto` `plan` `bypassPermissions` | ✓ | | GitHub Copilot | `hapi copilot` | ACP (`copilot --acp --stdio`) | ✓ | ✓ | `default` `read-only` `safe-yolo` `yolo` | ✓ | | Kimi | `hapi kimi` | ACP (`kimi acp`) | ✓ | ✓ | `default` `read-only` `safe-yolo` `yolo` | ✓ | | OpenCode | `hapi opencode` | ACP (`opencode acp`) | ✓ | ✓ | `default` `plan` `yolo` | ✓ | -| DeepSeek Harness | `hapi dsh` | ACP (`dsh-acp-demo` or configured server) | — | ✓ | Managed by DSH ACP composition | — | | Antigravity (agy) | `hapi agy` | Headless print mode (per-turn `agy -p` + NDJSON) | — | ✓ | `request-review` `always-proceed` | ✓ | | Pi | `hapi pi` | `pi --mode rpc` (JSON-line RPC over stdio) | — | ✓ | none (always auto-approve) | ✓ | | Gemini CLI | — | **Removed** — Google sunset the consumer Gemini CLI (2026-06-18) | — | — | — | — | @@ -30,7 +24,7 @@ Gemini is no longer launchable: `hapi gemini` is kept as a tombstone command tha ### ACP -Most remote integrations speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP) over stdio through a shared HAPI backend. ACP gives remote sessions bidirectional permission approval, plan/todo updates, question UI, model catalogs, and session resume via `session/load`. Cursor, Grok, Copilot, Kimi, OpenCode, and DeepSeek Harness remote sessions all run over ACP. DSH's official ACP server is intentionally automation-only and currently supports fresh sessions, committed assistant output, cancellation, and one-shot permissions; it does not provide native resume, model switching, MCP injection, or live tool/reasoning telemetry. +Most remote integrations speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP) over stdio through a shared HAPI backend. ACP gives remote sessions bidirectional permission approval, plan/todo updates, question UI, model catalogs, and session resume via `session/load`. Cursor, Grok, Copilot, Kimi, and OpenCode remote sessions all run over ACP. ### Permission modes @@ -38,10 +32,10 @@ Permission modes are per-agent — each flavor exposes its own set (see the matr ### Local and remote mode -Work **locally** in the terminal or **remotely** from web/phone, keeping the same conversation when you hand off. The support matrix shows which interfaces each agent offers; DSH, Pi, and Antigravity accept input only through HAPI's remote interface. +Every session is either **local** (driven from the terminal) or **remote** (driven from web/phone). Switching is seamless and keeps the same session state: -- **Remote → local:** continue in the terminal. If it shows the remote-control screen, press double-space to return to local input. -- **Local → remote:** send a message from the web UI or phone; HAPI handles the handoff. +- **Remote → local:** press double-space in the terminal. +- **Local → remote:** send a message from the web UI or phone; the session switches automatically. See [Seamless Handoff](./how-it-works.md#seamless-handoff) for details. @@ -52,7 +46,7 @@ hapi resume # Interactive picker of resumable sessions on this ma hapi resume # Resume a specific HAPI session ``` -`hapi resume` reopens the conversation on this machine, including active sessions you were using from your phone. Gemini and fresh-session-only DSH cannot be resumed. Pi and Antigravity resume with input still controlled from HAPI rather than the terminal. +`hapi resume` works for every flavor except Gemini. An active remote session is handed off to the local terminal first. Pi and Antigravity are the exceptions in the other direction: neither has a local input path, so their sessions always resume in remote mode. ## Cursor Agent @@ -236,42 +230,15 @@ HAPI also exposes Grok's common slash commands, discovers skills from `.grok/ski - OAuth/device-code login must be completed outside the HAPI Web UI. - Grok subscription, credit, and model availability are controlled by xAI. +- DeepSeek Harness structured question prompts are not yet mapped to HAPI's question UI. If a remote session reports authentication failure, run `grok login --device-auth` on the runner machine and retry. -## DeepSeek Harness - -`hapi dsh` uses the shared ACP transport and keeps DSH's runtime outside HAPI. The -default executable is `dsh-acp-demo`; configure a different ACP server or a -source checkout with `HAPI_DSH_ACP_COMMAND`, `HAPI_DSH_ACP_CONFIG`, or the JSON -argument array `HAPI_DSH_ACP_ARGS_JSON`. - -The official demo is published as `@deepseek-ai/dsh-acp-demo`; use an exact -version such as `0.1.0-rc.7` rather than npm's stale `latest` tag: - -```bash -npm install -g @deepseek-ai/dsh-acp-demo@0.1.0-rc.7 -``` - -A published package still needs a DSH Cordis composition/config. A source -checkout can be launched directly: - -```bash -export HAPI_DSH_ACP_COMMAND=pnpm -export HAPI_DSH_ACP_ARGS_JSON='["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]' -hapi dsh -``` - -DSH sessions are remote-only and fresh-session-only. HAPI does not inject MCP -servers or expose model/effort pickers because the official ACP contract leaves -those surfaces to the DSH composition. Pending one-shot permission requests -remain answerable in the standard HAPI UI, but the ACP composition owns the -overall permission policy. - ## Other agents -- **Claude Code** (`hapi claude`) — local sessions wrap the native TUI, remote sessions drive the Claude Agent SDK. [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) -- **Codex** (`hapi codex`) — OpenAI's Codex CLI, with terminal/Web control and a dedicated `plan` mode. See [Codex usage and limits](./codex-shared-sessions.md) for resume, terminal-exit behavior, and launch options. [openai/codex](https://github.com/openai/codex) +- **Claude Code** (`hapi` / `hapi claude`) — the default and recommended flavor; local sessions wrap the native TUI, remote sessions drive the Claude Agent SDK. [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) +- **Codex** (`hapi codex`) — OpenAI's Codex CLI; remote sessions talk to `codex app-server` over JSON-RPC, with a dedicated `plan` collaboration mode. [openai/codex](https://github.com/openai/codex) +- **DeepSeek Harness** (`hapi dsh`) — connects to the DSH Web runtime for native session creation, resume, model and reasoning selection, permission approvals, queued-message steering, and history import. Start `dsh web --port 3080` first; set `HAPI_DSH_URL` when it is not listening on the default `http://127.0.0.1:3080`. DSH sessions are remote-control only in HAPI because the Web runtime owns the native client. - **GitHub Copilot** (`hapi copilot`) — Copilot CLI over ACP (`copilot --acp --stdio`). [GitHub Copilot](https://github.com/features/copilot) - **Kimi** (`hapi kimi`) — Moonshot AI's Kimi CLI over ACP (`kimi acp`). [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) - **OpenCode** (`hapi opencode`) — the open-source OpenCode agent over ACP (`opencode acp`). [opencode.ai](https://opencode.ai) diff --git a/docs/local-deployment.md b/docs/local-deployment.md new file mode 100644 index 00000000..80f54cc7 --- /dev/null +++ b/docs/local-deployment.md @@ -0,0 +1,101 @@ +# Local deployment branch + +`local/deploy-main` is the source of the locally deployed HAPI binary. + +## Update rule + +Never reset this branch to `main` or recreate it from `main`. Update it with: + +```bash +git fetch upstream main +git merge upstream/main +``` + +Resolve conflicts by preserving the local features below. Build and deploy only +after typecheck and focused regression tests pass. + +## Local features carried by this branch + +- Claude local history import (`tiann/hapi#1429`) +- Codex and Cursor mid-turn steering (`tiann/hapi#1443`) +- Separate setting to pin all active sessions (`tiann/hapi#1447`) +- Claude custom models from `settings.json` (`customClaudeModels`); upstream PR + `tiann/hapi#1318` was closed, so this must remain local +- macOS case-safe Storage Usage module names, required for local typecheck/build + +When one of the open PRs is merged upstream, drop only the equivalent local +commits after verifying the merged implementation is present. Do not drop the +other local features. + +## Local work preserved on separate branches + +- Notification preferences and customizable push copy: + `feat/notification-preferences` (`tiann/hapi#1360`) +- HTML preview and completed-unseen marker: `feat/html-preview` +- Earlier recovery work, including Codex quick import: + `feat/recover-local-features` + +These branches must not be deleted during cleanup or upstream updates. + +## macOS executable deployment (important) + +The all-in-one Bun executable uses an ad-hoc Mach-O signature. macOS caches +that signature against the executable pathname and modification time. Replacing +`~/.hapi/bin/hapi` in place (including a temp-file + rename) or using a plain +`cp` can leave a stale code-signature cache. The next launch then fails with: + +```text +OS_REASON_CODESIGNING +embedded signature doesn't match attached signature +``` + +This is a deployment/install issue, not a HAPI application error. Use a fresh +versioned path for every build and keep the stable command path as a symlink. +Do not deploy by copying over the stable path. + +Example for the macOS arm64 local machine: + +```bash +set -euo pipefail +build=cli/dist-exe/bun-darwin-arm64/hapi +stamp=$(date +%Y%m%d-%H%M%S) +release="$HOME/.hapi/bin/hapi.$stamp" + +# Bun's linker signature is not suitable after installation; re-sign once. +codesign --remove-signature "$build" 2>/dev/null || true +codesign --force --sign - "$build" +codesign --verify --deep --strict "$build" + +# -p preserves the mtime covered by the code-signature cache. +cp -p "$build" "$release" +codesign --verify --deep --strict "$release" +"$release" --help >/dev/null + +# Keep the old target as a rollback point. If hapi is already a symlink, +# replace only the link; otherwise move the legacy regular file aside first. +stable="$HOME/.hapi/bin/hapi" +if [ -L "$stable" ]; then + old_target=$(readlink "$stable") +else + old_target="hapi.bak.$stamp" + mv "$stable" "$HOME/.hapi/bin/$old_target" +fi +ln -sfn "$(basename "$release")" "$stable" + +launchctl kickstart -k "gui/$(id -u)/com.hapi.hub" +sleep 2 +curl -fsS http://127.0.0.1:3006/health >/dev/null +"$stable" --version +``` + +If the health check fails, immediately restore the prior link and restart the +agent: + +```bash +ln -sfn "$old_target" "$HOME/.hapi/bin/hapi" +launchctl kickstart -k "gui/$(id -u)/com.hapi.hub" +``` + +Never use `cp`, `mv`, or `codesign` on the stable symlink target after the +launch agent has been started. Keep versioned binaries until the replacement +has been running and verified. diff --git a/docs/public/schemas/settings.schema.json b/docs/public/schemas/settings.schema.json index 062160f3..67afcca5 100644 --- a/docs/public/schemas/settings.schema.json +++ b/docs/public/schemas/settings.schema.json @@ -46,6 +46,15 @@ }, "description": "Allowed CORS origins. ENV: CORS_ORIGINS (comma-separated)" }, + "customClaudeModels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true, + "description": "Additional model names offered by the Claude model picker when using a custom ANTHROPIC_BASE_URL." + }, "telegramBotToken": { "type": "string", "description": "Telegram Bot API token from @BotFather. ENV: TELEGRAM_BOT_TOKEN" diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index 4332e7a2..593b210f 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -51,6 +51,8 @@ export interface Settings { * Env vars still win when set at process start (ops override). */ providerCredentials?: Partial> + /** Custom model names offered in the Claude model picker (e.g. DeepSeek via ANTHROPIC_BASE_URL). */ + customClaudeModels?: string[] } export function getSettingsFile(dataDir: string): string { diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index b15e5f34..59aa2ea4 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -396,7 +396,12 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session onSessionReady?.(data) }) - socket.on('messages-consumed', (data: { sid: string; localIds: string[]; clearQueuedThinkingGrace?: boolean; steered?: boolean }) => { + socket.on('messages-consumed', (data: { + sid: string + localIds: string[] + clearQueuedThinkingGrace?: boolean + steered?: boolean + }) => { if (!data || typeof data.sid !== 'string' || !Array.isArray(data.localIds)) { return } @@ -439,67 +444,14 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } // Emit only after the DB transaction succeeds. This is an ACK-level // batch contract, so preserve its original timestamp even when IDs are - // heterogeneous, replayed, or unknown. `steered` is a live-only signal - // (never persisted) that marks mid-turn delivery for the web badge. - onWebappEvent?.({ type: 'messages-consumed', sessionId: data.sid, localIds, invokedAt, ...(data.steered === true ? { steered: true } : {}) }) - }) - - socket.on('messages-indeterminate', (data: { sid: string; localIds: string[] }) => { - if (!data || typeof data.sid !== 'string' || !Array.isArray(data.localIds)) { - return - } - const localIds = data.localIds.filter((id): id is string => typeof id === 'string') - if (localIds.length === 0) return - const sessionAccess = resolveSessionAccess(data.sid) - if (!sessionAccess.ok) { - emitAccessError('session', data.sid, sessionAccess.reason) - return - } - try { - store.recordMessagesIndeterminate(data.sid, localIds, sessionAccess.value.namespace) - } catch (err) { - console.error('recordMessagesIndeterminate failed', err) - return - } - onWebappEvent?.({ type: 'messages-indeterminate', sessionId: data.sid, localIds }) - }) - - socket.on('messages-steer-state', ( - data: { sid: string; localIds: string[]; state: 'queued' | 'dispatching' }, - ack?: (response: { ok: boolean }) => void - ) => { - const reply = typeof ack === 'function' ? ack : () => {} - if (!data || typeof data.sid !== 'string' || !Array.isArray(data.localIds) - || (data.state !== 'queued' && data.state !== 'dispatching')) { - reply({ ok: false }) - return - } - const localIds = data.localIds.filter((id): id is string => typeof id === 'string') - if (localIds.length === 0) { - reply({ ok: false }) - return - } - const sessionAccess = resolveSessionAccess(data.sid) - if (!sessionAccess.ok) { - emitAccessError('session', data.sid, sessionAccess.reason) - reply({ ok: false }) - return - } - try { - const ok = store.recordSteerDeliveryState( - data.sid, - localIds, - data.state, - sessionAccess.value.namespace - ) - reply({ ok }) - if (ok && data.state === 'queued') { - onWebappEvent?.({ type: 'messages-requeued', sessionId: data.sid, localIds }) - } - } catch (err) { - console.error('recordSteerDeliveryState failed', err) - reply({ ok: false }) - } + // heterogeneous, replayed, or unknown. + onWebappEvent?.({ + type: 'messages-consumed', + sessionId: data.sid, + localIds, + invokedAt, + ...(data.steered === true ? { steered: true } : {}) + }) }) socket.on('session-end', (data: SessionEndPayload) => { diff --git a/hub/src/socket/handlers/terminal.test.ts b/hub/src/socket/handlers/terminal.test.ts index 3c57dd24..5a3b7ab2 100644 --- a/hub/src/socket/handlers/terminal.test.ts +++ b/hub/src/socket/handlers/terminal.test.ts @@ -79,10 +79,12 @@ function createHarness(options?: { sessionNamespace?: string maxTerminalsPerSocket?: number maxTerminalsPerSession?: number + role?: 'session-guest' }): Harness { const io = new FakeServer() const terminalSocket = new FakeSocket('terminal-socket') terminalSocket.data.namespace = 'default' + if (options?.role) terminalSocket.data.role = options.role const terminalRegistry = new TerminalRegistry({ idleTimeoutMs: 0 }) const cliNamespace = io.of('/cli') @@ -114,6 +116,25 @@ function lastEmit(socket: FakeSocket, event: string): EmittedEvent | undefined { } describe('terminal socket handlers', () => { + it('registers no terminal handlers for collaborative guests', () => { + const { terminalSocket, cliNamespace, terminalRegistry } = createHarness({ role: 'session-guest' }) + const cliSocket = new FakeSocket('cli-socket-1') + connectCliSocket(cliNamespace, cliSocket, 'session-1') + + terminalSocket.trigger('terminal:create', { + sessionId: 'session-1', + terminalId: 'terminal-1', + cols: 80, + rows: 24 + }) + terminalSocket.trigger('agent-terminal:subscribe', { sessionId: 'session-1' }) + terminalSocket.trigger('agent-terminal:input', { sessionId: 'session-1', data: 'a' }) + + expect(terminalRegistry.get('terminal-1')).toBeNull() + expect(terminalSocket.rooms.size).toBe(0) + expect(cliSocket.emitted).toHaveLength(0) + }) + it('rejects terminal creation when session is inactive', () => { const { terminalSocket, terminalRegistry } = createHarness({ sessionActive: false }) diff --git a/hub/src/socket/handlers/terminal.ts b/hub/src/socket/handlers/terminal.ts index 49921d1b..1de77a3b 100644 --- a/hub/src/socket/handlers/terminal.ts +++ b/hub/src/socket/handlers/terminal.ts @@ -31,6 +31,14 @@ export type TerminalHandlersDeps = { } export function registerTerminalHandlers(socket: SocketWithData, deps: TerminalHandlersDeps): void { + // Collaborative guests can chat in their shared session, but never receive + // a shell or the raw agent PTY. Keep this guard even though the namespace + // handshake also rejects guest JWTs, so a future connection-path refactor + // cannot accidentally expose terminal handlers. + if (socket.data.role === 'session-guest') { + return + } + const { io, getSession, terminalRegistry, maxTerminalsPerSocket, maxTerminalsPerSession } = deps const cliNamespace = io.of('/cli') const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 5dc8ebc4..102e75b6 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -17,7 +17,10 @@ import type { CliSocketWithData, SocketData, SocketServer } from './socketTypes' const jwtPayloadSchema = z.object({ uid: z.number(), - ns: z.string() + ns: z.string(), + sid: z.string().min(1).optional(), + role: z.literal('session-guest').optional(), + sht: z.string().min(1).optional() }) const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000 @@ -151,6 +154,9 @@ export function createSocketServer(deps: SocketServerDeps): { } socket.data.userId = parsed.data.uid socket.data.namespace = parsed.data.ns + if (parsed.data.role === 'session-guest') { + return next(new Error('Guest terminal access is disabled')) + } next() return } catch { diff --git a/hub/src/socket/socketTypes.ts b/hub/src/socket/socketTypes.ts index cec06bb0..8549e067 100644 --- a/hub/src/socket/socketTypes.ts +++ b/hub/src/socket/socketTypes.ts @@ -4,6 +4,9 @@ import type { DefaultEventsMap, Server, Socket } from 'socket.io' export type SocketData = { namespace?: string userId?: number + sessionId?: string + role?: 'session-guest' + shareToken?: string } export type SocketServer = Server diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 2a78d092..50443bea 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -287,7 +287,8 @@ export async function startHub(options: StartHubOptions = {}): Promise 0 && !fcmColumns.some((column) => column.name === 'push_key')) { this.db.exec('ALTER TABLE fcm_devices ADD COLUMN push_key TEXT') } + + this.db.exec(` + CREATE TABLE IF NOT EXISTS studio_rooms ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + namespace TEXT NOT NULL, + title TEXT NOT NULL, + share_token TEXT NOT NULL UNIQUE, + access_mode TEXT NOT NULL CHECK (access_mode IN ('view', 'contribute')), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_studio_rooms_namespace + ON studio_rooms(namespace, updated_at DESC); + + CREATE TABLE IF NOT EXISTS studio_posts ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + guest_id TEXT NOT NULL, + author_name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('discussion', 'suggestion')), + text TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open', 'submitted', 'dismissed')), + created_at INTEGER NOT NULL, + decided_at INTEGER, + submitted_text TEXT, + FOREIGN KEY (room_id) REFERENCES studio_rooms(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_studio_posts_room_created + ON studio_posts(room_id, created_at ASC); + `) } /** v24→v25: add durable unknown-delivery state for steers. */ @@ -984,7 +1071,7 @@ export class Store { } } - /** v25→v26: make empty immediate-queue heartbeat replay an indexed lookup. */ + /** v25→v26: preserve usage history and index immediate queued replay. */ private migrateFromV25ToV26(): void { this.db.exec(` CREATE INDEX IF NOT EXISTS idx_messages_immediate_queued @@ -993,6 +1080,93 @@ export class Store { AND local_id IS NOT NULL AND scheduled_at IS NULL AND delivery_state = 'queued'; + + DROP TABLE IF EXISTS usage_events_v26; + DROP TABLE IF EXISTS usage_scan_state_v26; + + CREATE TABLE usage_events_v26 ( + namespace TEXT NOT NULL DEFAULT 'default', + session_id TEXT NOT NULL, + source_key TEXT NOT NULL, + source_seq INTEGER NOT NULL, + created_at INTEGER NOT NULL, + agent TEXT NOT NULL, + model TEXT, + kind TEXT NOT NULL CHECK (kind IN ('delta', 'cumulative')), + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + last_input_tokens INTEGER, + last_output_tokens INTEGER, + last_cache_read_tokens INTEGER, + last_cache_creation_tokens INTEGER, + PRIMARY KEY (session_id, source_key) + ); + INSERT INTO usage_events_v26 ( + namespace, + session_id, + source_key, + source_seq, + created_at, + agent, + model, + kind, + input_tokens, + output_tokens, + cache_read_tokens, + cache_creation_tokens, + last_input_tokens, + last_output_tokens, + last_cache_read_tokens, + last_cache_creation_tokens + ) + SELECT + COALESCE(s.namespace, 'default'), + e.session_id, + e.source_key, + e.source_seq, + e.created_at, + e.agent, + e.model, + e.kind, + e.input_tokens, + e.output_tokens, + e.cache_read_tokens, + e.cache_creation_tokens, + e.last_input_tokens, + e.last_output_tokens, + e.last_cache_read_tokens, + e.last_cache_creation_tokens + FROM usage_events AS e + LEFT JOIN sessions AS s ON s.id = e.session_id; + DROP TABLE usage_events; + ALTER TABLE usage_events_v26 RENAME TO usage_events; + CREATE INDEX idx_usage_events_session_created + ON usage_events(session_id, created_at, source_seq); + CREATE INDEX idx_usage_events_created + ON usage_events(created_at); + CREATE INDEX idx_usage_events_order + ON usage_events(created_at, source_seq, session_id, source_key); + CREATE INDEX idx_usage_events_namespace_order + ON usage_events(namespace, created_at, source_seq, session_id, source_key); + + CREATE TABLE usage_scan_state_v26 ( + namespace TEXT NOT NULL DEFAULT 'default', + session_id TEXT PRIMARY KEY, + message_epoch INTEGER NOT NULL DEFAULT 0, + last_seq INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO usage_scan_state_v26 (namespace, session_id, message_epoch, last_seq) + SELECT + COALESCE(s.namespace, 'default'), + state.session_id, + state.message_epoch, + state.last_seq + FROM usage_scan_state AS state + LEFT JOIN sessions AS s ON s.id = state.session_id; + DROP TABLE usage_scan_state; + ALTER TABLE usage_scan_state_v26 RENAME TO usage_scan_state; `) } @@ -1054,6 +1228,26 @@ export class Store { `) } + private ensureSessionShareSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_shares ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + namespace TEXT NOT NULL, + share_token TEXT NOT NULL UNIQUE, + access_code_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_shares_session + ON session_shares(session_id, namespace, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_session_shares_namespace + ON session_shares(namespace, updated_at DESC); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v24.test.ts b/hub/src/store/migration-v24.test.ts index 4e727880..5f674b01 100644 --- a/hub/src/store/migration-v24.test.ts +++ b/hub/src/store/migration-v24.test.ts @@ -13,68 +13,41 @@ afterEach(() => { } }) -describe('schema migration v23 to v26', () => { - it('adds fcm_devices.push_key to a V23 database and keeps existing rows', () => { +describe('schema migration v23 to v24', () => { + it('adds studio rooms and posts without changing existing sessions', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v24-')) tempDirs.push(dir) const dbPath = join(dir, 'hapi.db') - new Store(dbPath).close() + const current = new Store(dbPath) + current.sessions.getOrCreateSession( + 'tag-1', + { name: 'Existing' }, + null, + 'default', + undefined, + undefined, + undefined, + 'session-1' + ) + current.close() + const legacy = new Database(dbPath) - legacy.exec(` - ALTER TABLE fcm_devices DROP COLUMN push_key; - INSERT INTO fcm_devices (namespace, token, platform, device_id, created_at, updated_at) - VALUES ('default', 'fcm-tok-1', 'phone', 'pixel-1', 1, 1); - PRAGMA user_version = 23; - `) + legacy.exec('DROP TABLE studio_posts; DROP TABLE studio_rooms; PRAGMA user_version = 23') legacy.close() const migrated = new Store(dbPath) - const internalDb = (migrated as unknown as { db: Database }).db - const columns = internalDb.prepare('PRAGMA table_info(fcm_devices)').all() as Array<{ name: string }> - const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } - - expect(columns.some((col) => col.name === 'push_key')).toBe(true) - const messageColumns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> - expect(messageColumns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(26) - - // Existing Android rows survive with a NULL push key. - const devices = migrated.fcm.getDevicesByNamespace('default') - expect(devices).toHaveLength(1) - expect(devices[0].token).toBe('fcm-tok-1') - expect(devices[0].pushKey).toBeNull() - - // And the migrated DB accepts new iOS rows. - migrated.fcm.upsertDevice('default', { - token: 'a1b2', - platform: 'ios', - deviceId: 'iphone-1', - pushKey: Buffer.alloc(32, 7).toString('base64') + const room = migrated.studios.createOrActivateRoom('session-1', 'default', 'Review room', 'contribute') + const post = migrated.studios.createPost({ + roomId: room.id, + guestId: 'guest-12345678', + authorName: 'Guest', + kind: 'suggestion', + text: 'Please add a regression test.' }) - expect(migrated.fcm.getDevicesByNamespace('default', ['ios'])).toHaveLength(1) - migrated.close() - }) - - it('adds messages.delivery_state to an already-upgraded V24 database', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v24-delivery-')) - tempDirs.push(dir) - const dbPath = join(dir, 'hapi.db') - - new Store(dbPath).close() - const legacy = new Database(dbPath) - legacy.exec(` - ALTER TABLE messages DROP COLUMN delivery_state; - PRAGMA user_version = 24; - `) - legacy.close() - - const migrated = new Store(dbPath) - const internalDb = (migrated as unknown as { db: Database }).db - const columns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> - const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } - expect(columns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(26) + expect(room.shareToken.length).toBeGreaterThan(30) + expect(post.status).toBe('open') + expect(migrated.sessions.getSessionByNamespace('session-1', 'default')).not.toBeNull() migrated.close() }) }) diff --git a/hub/src/store/sessionShareStore.test.ts b/hub/src/store/sessionShareStore.test.ts new file mode 100644 index 00000000..94fc2786 --- /dev/null +++ b/hub/src/store/sessionShareStore.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { Store } from "./index"; + +describe("SessionShareStore", () => { + it("rotates codes and verifies only the active share", () => { + const store = new Store(":memory:"); + const session = store.sessions.getOrCreateSession( + "share-test", + { flavor: "claude" }, + null, + "default", + ); + const first = store.sessionShares.createShare(session.id, "default"); + expect(first.accessCode).toMatch(/^\d{6}$/); + expect( + store.sessionShares.verifyCode(first.share.shareToken, first.accessCode) + ?.sessionId, + ).toBe(session.id); + + const second = store.sessionShares.createShare(session.id, "default"); + expect(second.share.shareToken).not.toBe(first.share.shareToken); + expect( + store.sessionShares.verifyCode(first.share.shareToken, first.accessCode), + ).toBeNull(); + expect( + store.sessionShares.verifyCode(second.share.shareToken, second.accessCode) + ?.sessionId, + ).toBe(session.id); + store.close(); + }); + + it("lists active shares by namespace without revoked shares", () => { + const store = new Store(":memory:"); + const firstSession = store.sessions.getOrCreateSession( + "share-list-1", + { flavor: "claude" }, + null, + "default", + ); + const secondSession = store.sessions.getOrCreateSession( + "share-list-2", + { flavor: "codex" }, + null, + "default", + ); + const otherNamespaceSession = store.sessions.getOrCreateSession( + "share-list-other", + { flavor: "codex" }, + null, + "other", + ); + + const first = store.sessionShares.createShare(firstSession.id, "default"); + const second = store.sessionShares.createShare(secondSession.id, "default"); + store.sessionShares.createShare(otherNamespaceSession.id, "other"); + store.sessionShares.revokeById(first.share.id, "default"); + + expect(store.sessionShares.getActiveByNamespace("default").map((share) => share.id)).toEqual([ + second.share.id, + ]); + expect(store.sessionShares.getActiveByNamespace("other")).toHaveLength(1); + store.close(); + }); +}); diff --git a/hub/src/store/sessionShareStore.ts b/hub/src/store/sessionShareStore.ts new file mode 100644 index 00000000..7a7baeb8 --- /dev/null +++ b/hub/src/store/sessionShareStore.ts @@ -0,0 +1,142 @@ +import type { Database } from "bun:sqlite"; +import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto"; +import { constantTimeEquals } from "../utils/crypto"; + +export type StoredSessionShare = { + id: string; + sessionId: string; + namespace: string; + shareToken: string; + accessCodeHash: string; + status: "active" | "revoked"; + createdAt: number; + updatedAt: number; +}; + +type SessionShareRow = { + id: string; + session_id: string; + namespace: string; + share_token: string; + access_code_hash: string; + status: "active" | "revoked"; + created_at: number; + updated_at: number; +}; + +function mapRow(row: SessionShareRow): StoredSessionShare { + return { + id: row.id, + sessionId: row.session_id, + namespace: row.namespace, + shareToken: row.share_token, + accessCodeHash: row.access_code_hash, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function hashSessionShareCode(code: string): string { + return createHash("sha256").update(code.trim()).digest("hex"); +} + +export class SessionShareStore { + constructor(private readonly db: Database) {} + + createShare( + sessionId: string, + namespace: string, + ): { share: StoredSessionShare; accessCode: string } { + return this.db.transaction(() => { + const now = Date.now(); + this.db + .prepare( + ` + UPDATE session_shares SET status = 'revoked', updated_at = ? + WHERE session_id = ? AND namespace = ? AND status = 'active' + `, + ) + .run(now, sessionId, namespace); + + const shareToken = randomBytes(32).toString("base64url"); + const accessCode = String(randomInt(0, 1_000_000)).padStart(6, "0"); + const id = randomUUID(); + this.db + .prepare( + ` + INSERT INTO session_shares ( + id, session_id, namespace, share_token, access_code_hash, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?) + `, + ) + .run( + id, + sessionId, + namespace, + shareToken, + hashSessionShareCode(accessCode), + now, + now, + ); + return { share: this.getById(id, namespace)!, accessCode }; + })(); + } + + getById(id: string, namespace: string): StoredSessionShare | null { + const row = this.db + .prepare("SELECT * FROM session_shares WHERE id = ? AND namespace = ?") + .get(id, namespace) as SessionShareRow | undefined; + return row ? mapRow(row) : null; + } + + getActiveBySession( + sessionId: string, + namespace: string, + ): StoredSessionShare | null { + const row = this.db + .prepare( + "SELECT * FROM session_shares WHERE session_id = ? AND namespace = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + ) + .get(sessionId, namespace) as SessionShareRow | undefined; + return row ? mapRow(row) : null; + } + + getActiveByNamespace(namespace: string): StoredSessionShare[] { + const rows = this.db + .prepare( + "SELECT * FROM session_shares WHERE namespace = ? AND status = 'active' ORDER BY updated_at DESC", + ) + .all(namespace) as SessionShareRow[]; + return rows.map(mapRow); + } + + getActiveByToken(token: string): StoredSessionShare | null { + const row = this.db + .prepare( + "SELECT * FROM session_shares WHERE share_token = ? AND status = 'active'", + ) + .get(token) as SessionShareRow | undefined; + return row ? mapRow(row) : null; + } + + verifyCode(token: string, code: string): StoredSessionShare | null { + const share = this.getActiveByToken(token); + if ( + !share || + !constantTimeEquals(hashSessionShareCode(code), share.accessCodeHash) + ) + return null; + return share; + } + + revokeById(id: string, namespace: string): boolean { + const result = this.db + .prepare( + "UPDATE session_shares SET status = 'revoked', updated_at = ? WHERE id = ? AND namespace = ? AND status = 'active'", + ) + .run(Date.now(), id, namespace); + return result.changes > 0; + } +} diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index c0d23236..18fb3b30 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -60,6 +60,7 @@ const SIMPLE_RESUME_TOKENS = [ 'opencodeSessionId', 'grokSessionId', 'cursorSessionId', + 'dshSessionId', 'kimiSessionId', 'copilotSessionId', 'piSessionId' diff --git a/hub/src/store/studioStore.test.ts b/hub/src/store/studioStore.test.ts new file mode 100644 index 00000000..a0f87bb5 --- /dev/null +++ b/hub/src/store/studioStore.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { Store } from './index' + +const stores: Store[] = [] + +afterEach(() => { + for (const store of stores.splice(0)) store.close() +}) + +describe('StudioStore', () => { + it('isolates owner lookup by namespace and revokes public tokens', () => { + const store = new Store(':memory:') + stores.push(store) + store.sessions.getOrCreateSession('tag-a', {}, null, 'alpha', undefined, undefined, undefined, 'session-a') + const room = store.studios.createOrActivateRoom('session-a', 'alpha', 'Room', 'contribute') + + expect(store.studios.getRoomById(room.id, 'beta')).toBeNull() + expect(store.studios.getActiveRoomByToken(room.shareToken)?.id).toBe(room.id) + expect(store.studios.revokeRoom(room.id, 'alpha')).toBe(true) + expect(store.studios.getActiveRoomByToken(room.shareToken)).toBeNull() + const reopened = store.studios.createOrActivateRoom('session-a', 'alpha', 'Room', 'contribute') + expect(reopened.shareToken).not.toBe(room.shareToken) + expect(store.studios.getActiveRoomByToken(room.shareToken)).toBeNull() + }) + + it('claims a suggestion once', () => { + const store = new Store(':memory:') + stores.push(store) + store.sessions.getOrCreateSession('tag-a', {}, null, 'alpha', undefined, undefined, undefined, 'session-a') + const room = store.studios.createOrActivateRoom('session-a', 'alpha', 'Room', 'contribute') + const post = store.studios.createPost({ + roomId: room.id, + guestId: 'guest-12345678', + authorName: 'Guest', + kind: 'suggestion', + text: 'Test this' + }) + + expect(store.studios.decidePost(post.id, room.id, 'submitted', 'Edited')?.status).toBe('submitted') + expect(store.studios.decidePost(post.id, room.id, 'submitted', 'Again')).toBeNull() + }) + + it('returns the newest posts when the room has more than the page limit', () => { + const store = new Store(':memory:') + stores.push(store) + store.sessions.getOrCreateSession('tag-a', {}, null, 'alpha', undefined, undefined, undefined, 'session-a') + const room = store.studios.createOrActivateRoom('session-a', 'alpha', 'Room', 'contribute') + for (let index = 0; index < 205; index += 1) { + store.studios.createPost({ + roomId: room.id, + guestId: 'guest-12345678', + authorName: 'Guest', + kind: 'discussion', + text: `post-${index}`, + createdAt: index + }) + } + const posts = store.studios.listPosts(room.id) + expect(posts).toHaveLength(200) + expect(posts[0]?.text).toBe('post-5') + expect(posts.at(-1)?.text).toBe('post-204') + }) + + it('limits kinds independently', () => { + const store = new Store(':memory:') + stores.push(store) + store.sessions.getOrCreateSession('tag-a', {}, null, 'alpha', undefined, undefined, undefined, 'session-a') + const room = store.studios.createOrActivateRoom('session-a', 'alpha', 'Room', 'contribute') + store.studios.createPost({ roomId: room.id, guestId: 'guest-12345678', authorName: 'Guest', kind: 'discussion', text: 'discussion', createdAt: 0 }) + for (let index = 1; index <= 205; index += 1) { + store.studios.createPost({ roomId: room.id, guestId: 'guest-12345678', authorName: 'Guest', kind: 'suggestion', text: `suggestion-${index}`, createdAt: index }) + } + expect(store.studios.listPostsByKind(room.id, 'discussion')).toHaveLength(1) + expect(store.studios.listPostsByKind(room.id, 'suggestion', null)).toHaveLength(205) + }) + + it('enforces a durable lifetime post limit atomically', () => { + const store = new Store(':memory:') + stores.push(store) + store.sessions.getOrCreateSession('tag-a', {}, null, 'alpha', undefined, undefined, undefined, 'session-a') + const room = store.studios.createOrActivateRoom('session-a', 'alpha', 'Room', 'contribute') + const input = { roomId: room.id, guestId: 'guest-12345678', authorName: 'Guest', kind: 'discussion' as const, text: 'Post' } + + expect(store.studios.createPostWithinLimit(input, 1)).not.toBeNull() + expect(store.studios.createPostWithinLimit(input, 1)).toBeNull() + expect(store.studios.listPosts(room.id, 10)).toHaveLength(1) + }) + + it('clears all room posts so a capped room can accept new contributions', () => { + const store = new Store(':memory:') + stores.push(store) + store.sessions.getOrCreateSession('tag-a', {}, null, 'alpha', undefined, undefined, undefined, 'session-a') + const room = store.studios.createOrActivateRoom('session-a', 'alpha', 'Room', 'contribute') + store.studios.createPost({ roomId: room.id, guestId: 'guest-12345678', authorName: 'Guest', kind: 'discussion', text: 'Post' }) + expect(store.studios.clearPosts(room.id)).toBe(1) + expect(store.studios.createPostWithinLimit({ roomId: room.id, guestId: 'guest-12345678', authorName: 'Guest', kind: 'discussion', text: 'Post again' }, 1)).not.toBeNull() + }) +}) diff --git a/hub/src/store/studioStore.ts b/hub/src/store/studioStore.ts new file mode 100644 index 00000000..800e9af0 --- /dev/null +++ b/hub/src/store/studioStore.ts @@ -0,0 +1,320 @@ +import type { Database } from 'bun:sqlite' +import { randomBytes, randomUUID } from 'node:crypto' +import type { + StoredStudioPost, + StoredStudioRoom, + StudioAccessMode, + StudioPostKind, + StudioPostStatus +} from './types' + +type StudioRoomRow = { + id: string + session_id: string + namespace: string + title: string + share_token: string + access_mode: StudioAccessMode + status: 'active' | 'revoked' + created_at: number + updated_at: number +} + +type StudioPostRow = { + id: string + room_id: string + guest_id: string + author_name: string + kind: StudioPostKind + text: string + status: StudioPostStatus + created_at: number + decided_at: number | null + submitted_text: string | null +} + +function mapRoom(row: StudioRoomRow): StoredStudioRoom { + return { + id: row.id, + sessionId: row.session_id, + namespace: row.namespace, + title: row.title, + shareToken: row.share_token, + accessMode: row.access_mode, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +function mapPost(row: StudioPostRow): StoredStudioPost { + return { + id: row.id, + roomId: row.room_id, + guestId: row.guest_id, + authorName: row.author_name, + kind: row.kind, + text: row.text, + status: row.status, + createdAt: row.created_at, + decidedAt: row.decided_at, + submittedText: row.submitted_text + } +} + +export class StudioStore { + constructor(private readonly db: Database) {} + + private insertPost(input: { + roomId: string + guestId: string + authorName: string + kind: StudioPostKind + text: string + createdAt?: number + }): StoredStudioPost { + const id = randomUUID() + const now = input.createdAt ?? Date.now() + this.db.prepare(` + INSERT INTO studio_posts ( + id, room_id, guest_id, author_name, kind, text, + status, created_at, decided_at, submitted_text + ) VALUES (?, ?, ?, ?, ?, ?, 'open', ?, NULL, NULL) + `).run(id, input.roomId, input.guestId, input.authorName, input.kind, input.text, now) + return this.getPost(id, input.roomId)! + } + + createOrActivateRoom( + sessionId: string, + namespace: string, + title: string, + accessMode: StudioAccessMode + ): StoredStudioRoom { + return this.db.transaction(() => { + const existing = this.getRoomBySession(sessionId, namespace) + const now = Date.now() + if (existing) { + const shareToken = existing.status === 'revoked' + ? randomBytes(32).toString('base64url') + : existing.shareToken + this.db.prepare(` + UPDATE studio_rooms + SET title = ?, access_mode = ?, share_token = ?, status = 'active', updated_at = ? + WHERE id = ? AND namespace = ? + `).run(title, accessMode, shareToken, now, existing.id, namespace) + return this.getRoomById(existing.id, namespace)! + } + + const id = randomUUID() + const shareToken = randomBytes(32).toString('base64url') + this.db.prepare(` + INSERT INTO studio_rooms ( + id, session_id, namespace, title, share_token, + access_mode, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?) + `).run(id, sessionId, namespace, title, shareToken, accessMode, now, now) + return this.getRoomById(id, namespace)! + })() + } + + getRoomById(id: string, namespace: string): StoredStudioRoom | null { + const row = this.db.prepare( + 'SELECT * FROM studio_rooms WHERE id = ? AND namespace = ?' + ).get(id, namespace) as StudioRoomRow | undefined + return row ? mapRoom(row) : null + } + + getRoomBySession(sessionId: string, namespace: string): StoredStudioRoom | null { + const row = this.db.prepare( + 'SELECT * FROM studio_rooms WHERE session_id = ? AND namespace = ?' + ).get(sessionId, namespace) as StudioRoomRow | undefined + return row ? mapRoom(row) : null + } + + getActiveRoomByToken(token: string): StoredStudioRoom | null { + const row = this.db.prepare( + "SELECT * FROM studio_rooms WHERE share_token = ? AND status = 'active'" + ).get(token) as StudioRoomRow | undefined + return row ? mapRoom(row) : null + } + + updateRoom( + id: string, + namespace: string, + input: { title?: string; accessMode?: StudioAccessMode; rotateToken?: boolean } + ): StoredStudioRoom | null { + const current = this.getRoomById(id, namespace) + if (!current) return null + const token = input.rotateToken ? randomBytes(32).toString('base64url') : current.shareToken + this.db.prepare(` + UPDATE studio_rooms + SET title = ?, access_mode = ?, share_token = ?, updated_at = ? + WHERE id = ? AND namespace = ? + `).run( + input.title ?? current.title, + input.accessMode ?? current.accessMode, + token, + Date.now(), + id, + namespace + ) + return this.getRoomById(id, namespace) + } + + revokeRoom(id: string, namespace: string): boolean { + const result = this.db.prepare(` + UPDATE studio_rooms SET status = 'revoked', updated_at = ? + WHERE id = ? AND namespace = ? + `).run(Date.now(), id, namespace) + return result.changes > 0 + } + + clearPosts(roomId: string): number { + const result = this.db.prepare('DELETE FROM studio_posts WHERE room_id = ?').run(roomId) + return result.changes + } + + listPosts(roomId: string, limit = 200): StoredStudioPost[] { + const rows = this.db.prepare(` + SELECT * FROM ( + SELECT * FROM studio_posts + WHERE room_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + ) + ORDER BY created_at ASC, id ASC + `).all(roomId, limit) as StudioPostRow[] + return rows.map(mapPost) + } + + listPostsByKind( + roomId: string, + kind: StudioPostKind, + limit: number | null = 200 + ): StoredStudioPost[] { + const limitClause = limit === null ? '' : 'LIMIT ?' + const params = limit === null ? [roomId, kind] : [roomId, kind, limit] + const rows = this.db.prepare(` + SELECT * FROM studio_posts + WHERE room_id = ? AND kind = ? + ORDER BY created_at DESC, id DESC + ${limitClause} + `).all(...params) as StudioPostRow[] + return rows.reverse().map(mapPost) + } + + listOpenSuggestions(roomId: string, limit: number | null = null): StoredStudioPost[] { + const limitClause = limit === null ? '' : 'LIMIT ?' + const params = limit === null ? [roomId] : [roomId, limit] + const rows = this.db.prepare(` + SELECT * FROM studio_posts + WHERE room_id = ? AND kind = 'suggestion' AND status = 'open' + ORDER BY created_at DESC, id DESC + ${limitClause} + `).all(...params) as StudioPostRow[] + return rows.reverse().map(mapPost) + } + + listOpenSuggestionsPage( + roomId: string, + limit = 200, + before?: { createdAt: number; id: string } + ): StoredStudioPost[] { + const beforeClause = before + ? 'AND (created_at < ? OR (created_at = ? AND id < ?))' + : '' + const params = before + ? [roomId, before.createdAt, before.createdAt, before.id, limit] + : [roomId, limit] + return (this.db.prepare(` + SELECT * FROM studio_posts + WHERE room_id = ? AND kind = 'suggestion' AND status = 'open' + ${beforeClause} + ORDER BY created_at DESC, id DESC + LIMIT ? + `).all(...params) as StudioPostRow[]).map(mapPost) + } + + countOpenSuggestions(roomId: string): number { + const row = this.db.prepare( + "SELECT COUNT(*) AS count FROM studio_posts WHERE room_id = ? AND kind = 'suggestion' AND status = 'open'" + ).get(roomId) as { count: number } + return row.count + } + + listResolvedSuggestions(roomId: string, limit = 50): StoredStudioPost[] { + const rows = this.db.prepare(` + SELECT * FROM studio_posts + WHERE room_id = ? AND kind = 'suggestion' AND status != 'open' + ORDER BY decided_at DESC, created_at DESC, id DESC + LIMIT ? + `).all(roomId, limit) as StudioPostRow[] + return rows.reverse().map(mapPost) + } + + createPost(input: { + roomId: string + guestId: string + authorName: string + kind: StudioPostKind + text: string + createdAt?: number + }): StoredStudioPost { + return this.insertPost(input) + } + + /** + * Atomically enforce a lifetime post cap for public rooms. The in-memory + * rate limiter protects bursts, while this durable cap protects the hub's + * SQLite file across restarts and rotating guest identities. + */ + createPostWithinLimit( + input: { + roomId: string + guestId: string + authorName: string + kind: StudioPostKind + text: string + createdAt?: number + }, + maxPosts: number + ): StoredStudioPost | null { + const limit = Math.max(1, Math.floor(maxPosts)) + return this.db.transaction(() => { + const row = this.db.prepare( + 'SELECT COUNT(*) AS count FROM studio_posts WHERE room_id = ?' + ).get(input.roomId) as { count: number } + if (row.count >= limit) return null + return this.insertPost(input) + })() + } + + getPost(id: string, roomId: string): StoredStudioPost | null { + const row = this.db.prepare( + 'SELECT * FROM studio_posts WHERE id = ? AND room_id = ?' + ).get(id, roomId) as StudioPostRow | undefined + return row ? mapPost(row) : null + } + + decidePost( + id: string, + roomId: string, + status: Extract, + submittedText?: string + ): StoredStudioPost | null { + const result = this.db.prepare(` + UPDATE studio_posts + SET status = ?, decided_at = ?, submitted_text = ? + WHERE id = ? AND room_id = ? AND status = 'open' + `).run(status, Date.now(), submittedText ?? null, id, roomId) + return result.changes > 0 ? this.getPost(id, roomId) : null + } + + reopenPost(id: string, roomId: string): void { + this.db.prepare(` + UPDATE studio_posts + SET status = 'open', decided_at = NULL, submitted_text = NULL + WHERE id = ? AND room_id = ? AND status = 'submitted' + `).run(id, roomId) + } +} diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 5a44d7ef..3a316d43 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -98,6 +98,35 @@ export type StoredScratchlistEntry = { attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } +export type StudioAccessMode = 'view' | 'contribute' +export type StudioPostKind = 'discussion' | 'suggestion' +export type StudioPostStatus = 'open' | 'submitted' | 'dismissed' + +export type StoredStudioRoom = { + id: string + sessionId: string + namespace: string + title: string + shareToken: string + accessMode: StudioAccessMode + status: 'active' | 'revoked' + createdAt: number + updatedAt: number +} + +export type StoredStudioPost = { + id: string + roomId: string + guestId: string + authorName: string + kind: StudioPostKind + text: string + status: StudioPostStatus + createdAt: number + decidedAt: number | null + submittedText: string | null +} + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/store/usage.ts b/hub/src/store/usage.ts index 3c3644fe..67d471d4 100644 --- a/hub/src/store/usage.ts +++ b/hub/src/store/usage.ts @@ -26,6 +26,7 @@ export type UsageScanState = { } type UsageEventRow = { + namespace: string session_id: string source_key: string source_seq: number @@ -66,6 +67,7 @@ function toUsageEvent(row: UsageEventRow): UsageEvent { export function recordUsageScan( db: Database, sessionId: string, + namespace: string, messageEpoch: number, lastSeq: number, events: UsageEvent[], @@ -79,6 +81,7 @@ export function recordUsageScan( if (events.length > 0) { const statement = db.prepare(` INSERT INTO usage_events ( + namespace, session_id, source_key, source_seq, @@ -95,6 +98,7 @@ export function recordUsageScan( last_cache_read_tokens, last_cache_creation_tokens ) VALUES ( + @namespace, @session_id, @source_key, @source_seq, @@ -113,6 +117,7 @@ export function recordUsageScan( ) ON CONFLICT(session_id, source_key) DO UPDATE SET + namespace = excluded.namespace, source_seq = excluded.source_seq, created_at = excluded.created_at, agent = excluded.agent, @@ -138,6 +143,7 @@ export function recordUsageScan( for (const event of events) { statement.run({ + namespace, session_id: event.sessionId, source_key: event.sourceKey, source_seq: event.sourceSeq, @@ -161,9 +167,10 @@ export function recordUsageScan( } db.prepare(` - INSERT INTO usage_scan_state (session_id, message_epoch, last_seq) - VALUES (?, ?, ?) + INSERT INTO usage_scan_state (namespace, session_id, message_epoch, last_seq) + VALUES (?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET + namespace = excluded.namespace, message_epoch = excluded.message_epoch, last_seq = CASE WHEN usage_scan_state.message_epoch = excluded.message_epoch @@ -171,7 +178,7 @@ export function recordUsageScan( ELSE excluded.last_seq END WHERE excluded.message_epoch >= usage_scan_state.message_epoch - `).run(sessionId, messageEpoch, lastSeq) + `).run(namespace, sessionId, messageEpoch, lastSeq) })() } @@ -181,6 +188,7 @@ export function getUsageEvents(db: Database, sessionIds: string[]): UsageEvent[] const placeholders = sessionIds.map(() => '?').join(', ') const rows = db.prepare(` SELECT + namespace, session_id, source_key, source_seq, @@ -204,6 +212,34 @@ export function getUsageEvents(db: Database, sessionIds: string[]): UsageEvent[] return rows.map(toUsageEvent) } +/** Read usage history for a namespace, including rows whose session was deleted. */ +export function getUsageEventsByNamespace(db: Database, namespace: string): UsageEvent[] { + const rows = db.prepare(` + SELECT + namespace, + session_id, + source_key, + source_seq, + created_at, + agent, + model, + kind, + input_tokens, + output_tokens, + cache_read_tokens, + cache_creation_tokens, + last_input_tokens, + last_output_tokens, + last_cache_read_tokens, + last_cache_creation_tokens + FROM usage_events + WHERE namespace = ? + ORDER BY created_at ASC, source_seq ASC, session_id ASC, source_key ASC + `).all(namespace) as UsageEventRow[] + + return rows.map(toUsageEvent) +} + export function getUsageScanStates(db: Database, sessionIds: string[]): Map { if (sessionIds.length === 0) return new Map() @@ -226,6 +262,7 @@ export function transferUsageSession(db: Database, fromSessionId: string, toSess db.transaction(() => { db.prepare(` INSERT OR IGNORE INTO usage_events ( + namespace, session_id, source_key, source_seq, @@ -244,6 +281,7 @@ export function transferUsageSession(db: Database, fromSessionId: string, toSess ) SELECT ?, + COALESCE((SELECT namespace FROM sessions WHERE id = ?), usage_events.namespace), source_key, source_seq, created_at, @@ -260,7 +298,7 @@ export function transferUsageSession(db: Database, fromSessionId: string, toSess last_cache_creation_tokens FROM usage_events WHERE session_id = ? - `).run(toSessionId, fromSessionId) + `).run(toSessionId, toSessionId, fromSessionId) db.prepare('DELETE FROM usage_events WHERE session_id = ?').run(fromSessionId) db.prepare('DELETE FROM usage_scan_state WHERE session_id IN (?, ?)').run(fromSessionId, toSessionId) })() diff --git a/hub/src/store/usageStore.ts b/hub/src/store/usageStore.ts index e0831305..907b0f5c 100644 --- a/hub/src/store/usageStore.ts +++ b/hub/src/store/usageStore.ts @@ -2,6 +2,7 @@ import type { Database } from 'bun:sqlite' import { getUsageEvents, + getUsageEventsByNamespace, getUsageScanStates, recordUsageScan, transferUsageSession, @@ -14,18 +15,23 @@ export class UsageStore { recordScan( sessionId: string, + namespace: string, messageEpoch: number, lastSeq: number, events: UsageEvent[], replaceEvents: boolean ): void { - recordUsageScan(this.db, sessionId, messageEpoch, lastSeq, events, replaceEvents) + recordUsageScan(this.db, sessionId, namespace, messageEpoch, lastSeq, events, replaceEvents) } getEvents(sessionIds: string[]): UsageEvent[] { return getUsageEvents(this.db, sessionIds) } + getEventsByNamespace(namespace: string): UsageEvent[] { + return getUsageEventsByNamespace(this.db, namespace) + } + getScanStates(sessionIds: string[]): Map { return getUsageScanStates(this.db, sessionIds) } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 90346e13..1b33ffe2 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -4,7 +4,9 @@ import { ArchiveCodexSessionRpcResponseSchema, AgentAvailabilityResponseSchema, CursorChatStoreStatusSchema, + ListClaudeSessionsRpcResponseSchema, ListCodexSessionsRpcResponseSchema, + ListDshSessionsRpcResponseSchema, ListPiSessionsRpcResponseSchema } from '@hapi/protocol/apiTypes' import type { @@ -16,6 +18,7 @@ import type { CursorModelSummary, CursorModelsResponse, CursorChatStoreStatus, + DshModelsResponse, DeleteUploadResponse, DirectoryEntry, FileReadResponse, @@ -24,8 +27,10 @@ import type { CopilotModelsResponse, GrokModelsResponse, GrokReasoningEffortResponse, + ListClaudeSessionsRpcResponse, ListDirectoryResponse, ListCodexSessionsRpcResponse, + ListDshSessionsRpcResponse, ListPiSessionsRpcResponse, ArchiveCodexSessionRpcResponse, OpencodeModelsResponse, @@ -81,6 +86,9 @@ export type RpcPathExistsResponse = PathExistsResponse export type RpcCodexModel = CodexModelSummary export type RpcListCodexModelsResponse = CodexModelsResponse export type RpcListCodexSessionsResponse = ListCodexSessionsRpcResponse +export type RpcListClaudeSessionsResponse = ListClaudeSessionsRpcResponse +export type RpcListDshSessionsResponse = ListDshSessionsRpcResponse +export type RpcListDshModelsResponse = DshModelsResponse export type RpcListPiSessionsResponse = ListPiSessionsRpcResponse export type RpcArchiveCodexSessionResponse = ArchiveCodexSessionRpcResponse export type RpcCursorModel = CursorModelSummary @@ -400,11 +408,39 @@ export class RpcGateway { return ListCodexSessionsRpcResponseSchema.parse(result) } + async listClaudeSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { + const result = await this.machineRpc(machineId, RPC_METHODS.ListClaudeSessions, { cwd: cwd ?? null, sessionIds }, MODEL_LIST_RPC_TIMEOUT_MS) + return ListClaudeSessionsRpcResponseSchema.parse(result) + } + async listPiSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { const result = await this.machineRpc(machineId, RPC_METHODS.ListPiSessions, { cwd: cwd ?? null, sessionIds }, MODEL_LIST_RPC_TIMEOUT_MS) return ListPiSessionsRpcResponseSchema.parse(result) } + async listDshSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { + const result = await this.machineRpc(machineId, RPC_METHODS.ListDshSessions, { cwd: cwd ?? null, sessionIds }, MODEL_LIST_RPC_TIMEOUT_MS) + return ListDshSessionsRpcResponseSchema.parse(result) + } + + async listDshModelsForMachine(machineId: string): Promise { + return await this.machineRpc( + machineId, + RPC_METHODS.ListDshModels, + {}, + MODEL_LIST_RPC_TIMEOUT_MS + ) as RpcListDshModelsResponse + } + + async listDshModelsForSession(sessionId: string): Promise { + return await this.sessionRpc( + sessionId, + RPC_METHODS.ListDshModels, + {}, + MODEL_LIST_RPC_TIMEOUT_MS + ) as RpcListDshModelsResponse + } + async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { const result = await this.machineRpc(machineId, RPC_METHODS.ArchiveCodexSession, { sessionId }, MODEL_LIST_RPC_TIMEOUT_MS) return ArchiveCodexSessionRpcResponseSchema.parse(result) @@ -463,8 +499,8 @@ export class RpcGateway { } /** - * Ask the CLI to deliver one queued message into the active Pi turn - * (Pi native steer). Only the pi flavor registers this handler. + * Ask the CLI to deliver one queued message into the active turn. + * Pi, Codex, Cursor, and DeepSeek Harness launchers register this handler. */ async steerQueuedMessage( sessionId: string, @@ -476,6 +512,14 @@ export class RpcGateway { } } + async retryCodexTurn(sessionId: string): Promise<{ retried: boolean; error?: string; sessionId?: string }> { + return await this.sessionRpc(sessionId, RPC_METHODS.RetryCodexTurn, {}) as { + retried: boolean + error?: string + sessionId?: string + } + } + async forkConversation( sessionId: string, params: { messageLocalId?: string } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 7ae05e17..eac7d925 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1133,8 +1133,11 @@ export class SessionCache { if (options.deleteOldSession) { this.store.workGraph.reassignNotifySession(namespace, oldSessionId, newSessionId) } + // Usage is a durable ledger. Transfer any indexed rows before a + // session merge can delete the source, even when no message moved in + // this merge attempt. + this.store.usage.transferSession(oldSessionId, newSessionId) if (movedMessages.moved > 0) { - this.store.usage.transferSession(oldSessionId, newSessionId) if (!options.deleteOldSession) { this.publisher.emit({ type: 'messages-invalidated', sessionId: oldSessionId, namespace }) } @@ -1501,12 +1504,16 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable - ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId' | 'agySessionId' | 'copilotSessionId'; value: string; dedupeKey: string; machineId?: string } | null { - const scoped = (field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId' | 'agySessionId' | 'copilotSessionId', value: string) => ({ + ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'dshSessionId' | 'piSessionId' | 'agySessionId' | 'copilotSessionId'; value: string; dedupeKey: string; machineId?: string } | null { + const scoped = (field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'dshSessionId' | 'piSessionId' | 'agySessionId' | 'copilotSessionId', value: string) => ({ field, value, - dedupeKey: field === 'piSessionId' ? `${field}:${metadata.machineId ?? 'unscoped'}:${value}` : `${field}:${value}`, - ...(field === 'piSessionId' && metadata.machineId ? { machineId: metadata.machineId } : {}) + dedupeKey: field === 'piSessionId' || field === 'dshSessionId' + ? `${field}:${metadata.machineId ?? 'unscoped'}:${value}` + : `${field}:${value}`, + ...((field === 'piSessionId' || field === 'dshSessionId') && metadata.machineId + ? { machineId: metadata.machineId } + : {}) }) if (metadata.codexSessionId) return scoped('codexSessionId', metadata.codexSessionId) if (metadata.claudeSessionId) return scoped('claudeSessionId', metadata.claudeSessionId) @@ -1514,6 +1521,7 @@ export class SessionCache { if (metadata.opencodeSessionId) return scoped('opencodeSessionId', metadata.opencodeSessionId) if (metadata.grokSessionId) return scoped('grokSessionId', metadata.grokSessionId) if (metadata.cursorSessionId) return scoped('cursorSessionId', metadata.cursorSessionId) + if (metadata.dshSessionId) return scoped('dshSessionId', metadata.dshSessionId) if (metadata.piSessionId) return scoped('piSessionId', metadata.piSessionId) if (metadata.agySessionId) return scoped('agySessionId', metadata.agySessionId) if (metadata.copilotSessionId) return scoped('copilotSessionId', metadata.copilotSessionId) @@ -1545,7 +1553,8 @@ export class SessionCache { const currentSession = this.sessions.get(sessionId) const candidates: { id: string; session: Session }[] = [] if (currentSession?.metadata && currentSession.metadata[agentId.field] === agentId.value) { - if (agentId.field !== 'piSessionId' || currentSession.metadata.machineId === agentId.machineId) { + if ((agentId.field !== 'piSessionId' && agentId.field !== 'dshSessionId') + || currentSession.metadata.machineId === agentId.machineId) { candidates.push({ id: sessionId, session: currentSession }) } } @@ -1554,7 +1563,8 @@ export class SessionCache { if (existing.namespace !== session.namespace) continue if (!existing.metadata) continue if (existing.metadata[agentId.field] !== agentId.value) continue - if (agentId.field === 'piSessionId' && existing.metadata.machineId !== agentId.machineId) continue + if ((agentId.field === 'piSessionId' || agentId.field === 'dshSessionId') + && existing.metadata.machineId !== agentId.machineId) continue candidates.push({ id: existingId, session: existing }) } diff --git a/hub/src/sync/steerQueuedMessage.test.ts b/hub/src/sync/steerQueuedMessage.test.ts index 1704a959..be0256d7 100644 --- a/hub/src/sync/steerQueuedMessage.test.ts +++ b/hub/src/sync/steerQueuedMessage.test.ts @@ -48,7 +48,7 @@ describe('SyncEngine.steerQueuedMessage', () => { } }) - it('rejects unsupported flavors without invoking the CLI', async () => { + it('rejects sessions without native steer support without invoking the CLI', async () => { const { store, engine } = createEngine() try { const session = engine.getOrCreateSession( @@ -63,7 +63,7 @@ describe('SyncEngine.steerQueuedMessage', () => { expect(result).toEqual({ status: 'failed', - error: 'Steering is only supported for Pi, Codex, and Cursor ACP sessions', + error: 'Steering is not supported for this agent', localId: null }) } finally { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index f89103fe..1163258b 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -12,11 +12,10 @@ import { cliBinaryUpdatedOnDisk, isMachineCapabilitySkewed, } from '@hapi/protocol/runnerCapabilities' -import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, RewindConversationErrorCode, SlashCommandsResponse } from '@hapi/protocol/apiTypes' +import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { SteerQueuedMessageResponse } from '@hapi/protocol/schemas' -import type { ImplementCodexPlanResult } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' -import { hasConversationMessageContent, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' import { randomUUID } from 'node:crypto' import type { Store, CancelQueuedMessageResult } from '../store' @@ -31,7 +30,6 @@ import { MachineCache, type Machine } from './machineCache' import { MessageService, type RetryIndeterminateMessageResult } from './messageService' import { createTitleSuggestionService, type TitleSuggestionService } from './titleSuggestion' import { selectForkTranscriptPrefix } from './forkTranscript' -import { buildForkSessionSummary } from './forkSessionSummary' import { RpcGateway, RpcTargetMissingError, @@ -45,11 +43,13 @@ import { type RpcListAgyModelsResponse, type RpcListPiModelsResponse, type RpcListCodexModelsResponse, + type RpcListClaudeSessionsResponse, + type RpcListDshSessionsResponse, + type RpcListDshModelsResponse, type RpcListPiSessionsResponse, type RpcArchiveCodexSessionResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, - type RpcListOpencodeModelVariantsResponse, type RpcListGrokModelsResponse, type RpcListCopilotModelsResponse, type RpcListGrokReasoningEffortOptionsResponse, @@ -80,10 +80,12 @@ export type { RpcListAgyModelsResponse, RpcListPiModelsResponse, RpcListCodexModelsResponse, + RpcListClaudeSessionsResponse, + RpcListDshSessionsResponse, + RpcListDshModelsResponse, RpcListPiSessionsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, - RpcListOpencodeModelVariantsResponse, RpcListGrokModelsResponse, RpcListCopilotModelsResponse, RpcListGrokReasoningEffortOptionsResponse, @@ -111,7 +113,7 @@ export type LocalResumeTargetResult = export type LocalHandoffResult = | { type: 'success' } - | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' | 'control_mode_not_applicable' } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' } export type ClearOpencodeSessionResult = | { type: 'success'; sessionId: string } @@ -214,16 +216,6 @@ export class SyncEngine { ) { this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) - this.eventPublisher.subscribe((event) => { - if (event.type === 'message-received') { - if (!this.sessionCache.getSession(event.sessionId)?.hasConversationContent - && hasConversationMessageContent(event.message.content)) { - this.sessionCache.refreshConversationContent(event.sessionId) - } - } else if (event.type === 'message-cancelled' || event.type === 'messages-invalidated') { - this.sessionCache.refreshConversationContent(event.sessionId) - } - }) this.machineCache = new MachineCache(store, this.eventPublisher) this.messageService = new MessageService( store, @@ -421,8 +413,8 @@ export class SyncEngine { return this.messageService.getQueuedState(sessionId, localIds) } - getSessionExport(sessionId: string, session: Session, options?: { force?: boolean }): HapiSessionExportResult { - return this.messageService.getSessionExport(sessionId, session, options) + getSessionExport(sessionId: string, session: Session): HapiSessionExportResult { + return this.messageService.getSessionExport(sessionId, session) } getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] { @@ -1050,10 +1042,7 @@ export class SyncEngine { /** * Ask the CLI to deliver one waiting-queue message into the active turn - * (native steer). Supported for Pi, Codex, and Cursor ACP sessions; the - * CLI's `steer-queued-message` handler is registered per flavor. Legacy - * stream-json Cursor sessions and other flavors are rejected by the - * capability gate. + * (Pi native steer, Codex turn/steer, Cursor ACP soft send, or DSH steer). */ async steerQueuedMessage( sessionId: string, @@ -1064,9 +1053,9 @@ export class SyncEngine { return { status: 'failed', error: 'Session not found', localId: null } } if (!isSteeringSupportedForSession(session.metadata)) { - return { status: 'failed', error: 'Steering is only supported for Pi, Codex, and Cursor ACP sessions', localId: null } + return { status: 'failed', error: 'Steering is not supported for this agent', localId: null } } - if (session.agentState?.controlledByUser === true && !session.metadata?.capabilities?.concurrentClients) { + if (session.agentState?.controlledByUser === true) { return { status: 'failed', error: 'Steering is only available for remote sessions', localId: null } } @@ -1116,6 +1105,71 @@ export class SyncEngine { } } + async retryCodexTurn(sessionId: string): Promise<{ retried: boolean; error?: string; sessionId?: string }> { + const session = this.getSession(sessionId) + if (!session) return { retried: false, error: 'Session not found' } + if (this.resolveFlavor(session) !== 'codex') { + return { retried: false, error: 'Retry is only available for Codex sessions' } + } + if (session.agentState?.controlledByUser === true) { + return { retried: false, error: 'Retry is only available for remote sessions' } + } + + // The CLI keeps the original QueuedMessage in memory while it is + // connected. Once the process has gone idle and the session becomes + // inactive, that in-memory retry state is gone. Reopen the same + // Codex thread and enqueue the last user prompt as a durable fallback + // instead of returning a silent 409 to the web client. + const retryLastUserPrompt = async (targetSessionId: string): Promise<{ retried: boolean; error?: string; sessionId?: string }> => { + const messages = this.messageService.getMessages(targetSessionId, 200) + let prompt: string | undefined + for (let index = messages.length - 1; index >= 0; index -= 1) { + const roleWrapped = unwrapRoleWrappedRecordEnvelope(messages[index]?.content) + if (roleWrapped?.role !== 'user') continue + prompt = extractUserMessageText(roleWrapped.content) + if (prompt) break + } + if (!prompt) { + return { retried: false, error: 'No previous Codex prompt is available to retry' } + } + + try { + await this.sendMessage(targetSessionId, { text: prompt, deliveryMode: 'queue' }) + return { retried: true, sessionId: targetSessionId } + } catch (error) { + return { retried: false, error: error instanceof Error ? error.message : 'Failed to queue Codex retry' } + } + } + + if (!session.active) { + const resumed = await this.resumeSession(sessionId, session.namespace) + if (resumed.type === 'error') { + return { retried: false, error: resumed.message } + } + return await retryLastUserPrompt(resumed.sessionId) + } + + try { + const result = await this.rpcGateway.retryCodexTurn(sessionId) + if (result.retried || result.error !== 'No retryable Codex turn is available') { + return result + } + // Older runners and terminal event paths can lose the in-memory + // QueuedMessage even though the session is still active. Fall + // back to the durable last prompt in that case. + return await retryLastUserPrompt(sessionId) + } catch (error) { + // A runner that predates RetryCodexTurn (or a briefly + // disconnected CLI socket) reports a missing RPC handler. The + // durable prompt fallback still works in that situation and is + // preferable to surfacing a dead-end toast to the user. + if (error instanceof RpcTargetMissingError) { + return await retryLastUserPrompt(sessionId) + } + return { retried: false, error: error instanceof Error ? error.message : 'Retry failed' } + } + } + sweepImmediateQueuedOnSessionEnd(sessionId: string, invokedAt: number): void { this.messageService.sweepImmediateQueuedOnSessionEnd(sessionId, invokedAt) } @@ -1147,7 +1201,7 @@ export class SyncEngine { if (!session.active) { throw new Error('Session must be active') } - if (session.agentState?.controlledByUser === true && !session.metadata?.capabilities?.concurrentClients) { + if (session.agentState?.controlledByUser === true) { throw new Error('Conversation history actions require a remote session') } if (session.thinking) { @@ -1411,11 +1465,6 @@ export class SyncEngine { if (!rpcResult?.nativeSessionId) { return { type: 'error', message: 'Native fork did not return a session id' } } - if (rpcResult.sessionId) { - const child = await this.validateSharedChild(source, rpcResult.sessionId, rpcResult.nativeSessionId) - if (!child || child.metadata?.forkedFrom !== sessionId) return { type: 'error', message: 'Invalid shared-runtime fork binding' } - return { type: 'success', sessionId: child.id } - } // Native fork RPC can race CLI metadata/transcript updates. Construct // the child only from a fresh source snapshot, never the pre-RPC row. @@ -1436,13 +1485,11 @@ export class SyncEngine { const copiedLocalIds = new Set( prefix.flatMap((message) => (message.localId ? [message.localId] : [])) ) - const forkSummary = buildForkSessionSummary(source.metadata) const childMetadata: Record = { path: directory, host: source.metadata?.host ?? 'unknown', machineId, flavor, - ...(forkSummary ? { summary: forkSummary } : {}), forkedFrom: sessionId, startedBy: 'runner', capabilities: source.metadata?.capabilities, @@ -1603,7 +1650,7 @@ export class SyncEngine { sessionId: string, namespace: string, messageLocalId: string - ): Promise<{ type: 'success' } | { type: 'error'; message: string; code?: RewindConversationErrorCode; hydrateFailed?: boolean }> { + ): Promise<{ type: 'success' } | { type: 'error'; message: string; hydrateFailed?: boolean }> { if (this.historyActionsInFlight.has(sessionId)) { return { type: 'error', message: 'Conversation history action already in progress' } } @@ -1619,7 +1666,7 @@ export class SyncEngine { sessionId: string, namespace: string, messageLocalId: string - ): Promise<{ type: 'success' } | { type: 'error'; message: string; code?: RewindConversationErrorCode; hydrateFailed?: boolean }> { + ): Promise<{ type: 'success' } | { type: 'error'; message: string; hydrateFailed?: boolean }> { const access = this.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'not-found' ? 'Session not found' : 'Access denied' } @@ -1655,11 +1702,7 @@ export class SyncEngine { } if (rpcResult?.success !== true) { - return { - type: 'error', - message: rpcResult?.error ?? 'Native rewind failed', - ...(rpcResult?.success === false && rpcResult.code ? { code: rpcResult.code } : {}) - } + return { type: 'error', message: rpcResult?.error ?? 'Native rewind failed' } } try { @@ -1670,13 +1713,7 @@ export class SyncEngine { ) this.scrubHistoryLocators(sessionId, namespace) this.sessionCache.rebuildTodosFromTranscript(sessionId) - this.eventPublisher.emit({ - type: 'messages-invalidated', - sessionId, - namespace, - reason: 'rewind', - truncateFromLocalId: rpcResult.truncateFromLocalId ?? messageLocalId - }) + this.eventPublisher.emit({ type: 'messages-invalidated', sessionId, namespace }) this.sessionCache.refreshSession(sessionId) return { type: 'success' } } catch (error) { @@ -1881,46 +1918,7 @@ export class SyncEngine { }) } - private async validateSharedChild(source: Session, id: string, nativeId?: string): Promise { - if (!source.metadata?.capabilities?.concurrentClients || id === source.id) return null - const deadline = Date.now() + 5_000 - do { - const child = this.sessionCache.refreshSession(id) - if (child && child.namespace === source.namespace - && child.metadata?.machineId === source.metadata.machineId - && child.metadata?.hostPid === source.metadata.hostPid - && child.metadata?.capabilities?.concurrentClients - && child.metadata.codexSessionId && (!nativeId || child.metadata.codexSessionId === nativeId)) return child - await new Promise(resolve => setTimeout(resolve, 50)) - } while (Date.now() < deadline) - return null - } - - async clearConversation(sessionId: string, namespace: string): Promise<{ sessionId: string }> { - const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) - if (!access.ok || !access.session.active || !access.session.metadata?.capabilities?.concurrentClients) { - throw new Error('Clear requires an active shared session') - } - const result = await this.rpcGateway.clearConversation(access.sessionId) - const child = await this.validateSharedChild(access.session, result.sessionId) - if (!child) throw new Error('Invalid shared-runtime clear binding') - // No superseded-session redirect: only the initiating client navigates. - return { sessionId: child.id } - } - - async implementCodexPlan(sessionId: string, namespace: string, planId: string): Promise { - const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) - if (!access.ok || !access.session.active || access.session.metadata?.flavor !== 'codex' - || !access.session.metadata.capabilities?.concurrentClients) { - return { ok: false, code: 'unavailable', error: 'Plan implementation requires an active shared Codex session' } - } - // CLI validates native history and deduplicates already accepted actions. - // A stale Hub plan id must not prevent a safe retry of a lost RPC reply. - return await this.rpcGateway.implementCodexPlan(access.sessionId, planId) - } - async switchSession(sessionId: string, to: 'remote' | 'local'): Promise { - if (this.getSession(sessionId)?.metadata?.capabilities?.concurrentClients) throw new Error('control_mode_not_applicable') if (this.historyActionsInFlight.has(sessionId)) { throw new Error('Conversation history action already in progress') } @@ -2015,7 +2013,7 @@ export class SyncEngine { collaborationMode?: CodexCollaborationMode, copilotAgentMode?: CopilotAgentMode, startingMode?: 'remote' | 'pty' - ): ReturnType { + ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, directory, @@ -2104,11 +2102,9 @@ export class SyncEngine { const operation = access.session.metadata?.opencodeClearOperation if (!operation) return { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } if (operation.state === 'aborted') { - if (replacementSessionId !== operation.replacementSessionId) { - return { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } - } - this.sessionCache.refreshConversationContent(operation.replacementSessionId) - return { type: 'success', sessionId } + return replacementSessionId === operation.replacementSessionId + ? { type: 'success', sessionId } + : { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } } const required = { replacementSessionId, state: expectedState, requireInactive } for (let attempt = 0; attempt < 3; attempt += 1) { @@ -2117,7 +2113,6 @@ export class SyncEngine { const current = latest.metadata.opencodeClearOperation if (!current) break if (current.replacementSessionId === required.replacementSessionId && current.state === 'aborted') { - this.sessionCache.refreshConversationContent(current.replacementSessionId) return { type: 'success', sessionId } } if ((required.requireInactive && latest.active) @@ -2129,7 +2124,6 @@ export class SyncEngine { }, latest.metadataVersion, namespace, required) if (result.result === 'success') { this.sessionCache.refreshSession(sessionId) - this.sessionCache.refreshConversationContent(current.replacementSessionId) return { type: 'success', sessionId } } if (result.result !== 'version-mismatch') break @@ -2475,12 +2469,10 @@ export class SyncEngine { if (flavor === 'grok') return metadata.grokSessionId ?? null if (flavor === 'agy') return metadata.agySessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null + if (flavor === 'dsh') return metadata.dshSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null if (flavor === 'copilot') return metadata.copilotSessionId ?? null if (flavor === 'pi') return metadata.piSessionId ?? null - // The official DSH ACP server creates fresh sessions only; never fall - // through to a stale Claude id and advertise a false resume path. - if (flavor === 'dsh') return null return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace) } @@ -3432,14 +3424,11 @@ export class SyncEngine { } } - if (access.session.metadata?.capabilities?.concurrentClients) { - return { type: 'error', message: 'Shared sessions attach without handoff', code: 'control_mode_not_applicable' } - } if (!access.session.active) { return { type: 'success' } } - if (access.session.agentState?.controlledByUser === true && !access.session.metadata?.capabilities?.concurrentClients) { + if (access.session.agentState?.controlledByUser === true) { return { type: 'error', message: 'Session is already controlled by a local terminal', @@ -3618,6 +3607,7 @@ export class SyncEngine { && (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null) && (prev?.grokSessionId ?? null) === (next.grokSessionId ?? null) && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) + && (prev?.dshSessionId ?? null) === (next.dshSessionId ?? null) && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) && (prev?.agySessionId ?? null) === (next.agySessionId ?? null) @@ -3916,14 +3906,10 @@ export class SyncEngine { return false } - async checkPathsExist(machineId: string, paths: string[]): ReturnType { + async checkPathsExist(machineId: string, paths: string[]): Promise> { return await this.rpcGateway.checkPathsExist(machineId, paths) } - async getAgentAvailability(machineId: string): ReturnType { - return await this.rpcGateway.getAgentAvailability(machineId) - } - async listMachineDirectory(machineId: string, path: string, includeHidden?: boolean): Promise { return await this.rpcGateway.listMachineDirectory(machineId, path, includeHidden) } @@ -3980,11 +3966,8 @@ export class SyncEngine { return await this.rpcGateway.listSkills(sessionId, flavor) } - async listAgyModelsForMachine( - machineId: string, - options?: { refresh?: boolean } - ): Promise { - return await this.rpcGateway.listAgyModelsForMachine(machineId, options) + async listAgyModelsForMachine(machineId: string): Promise { + return await this.rpcGateway.listAgyModelsForMachine(machineId) } async listPiModelsForMachine(machineId: string): Promise { @@ -3995,10 +3978,6 @@ export class SyncEngine { return await this.rpcGateway.listCodexModelsForMachine(machineId) } - async listOpencodeModelVariantsForMachine(machineId: string, cwd?: string | null): Promise { - return await this.rpcGateway.listOpencodeModelVariantsForMachine(machineId, cwd) - } - async listCodexModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listCodexModelsForSession(sessionId) } @@ -4007,10 +3986,26 @@ export class SyncEngine { return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds) } + async listClaudeSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { + return await this.rpcGateway.listClaudeSessionsForMachine(machineId, cwd, sessionIds) + } + async listPiSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { return await this.rpcGateway.listPiSessionsForMachine(machineId, cwd, sessionIds) } + async listDshSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { + return await this.rpcGateway.listDshSessionsForMachine(machineId, cwd, sessionIds) + } + + async listDshModelsForMachine(machineId: string): Promise { + return await this.rpcGateway.listDshModelsForMachine(machineId) + } + + async listDshModelsForSession(sessionId: string): Promise { + return await this.rpcGateway.listDshModelsForSession(sessionId) + } + async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId) } diff --git a/hub/src/sync/syncEngineSteer.test.ts b/hub/src/sync/syncEngineSteer.test.ts new file mode 100644 index 00000000..f9c45e8b --- /dev/null +++ b/hub/src/sync/syncEngineSteer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +type EngineInternals = { + rpcGateway: { + steerQueuedMessage: ( + sessionId: string, + localId: string + ) => Promise<{ steered: boolean; error?: string }> + } +} + +function setup(options: { flavor?: 'codex' | 'claude'; scheduledAt?: number } = {}) { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + engine.stop() + const session = engine.getOrCreateSession( + 'steer-session', + { + path: '/tmp/steer-session', + host: 'localhost', + flavor: options.flavor ?? 'codex' + }, + null, + 'default' + ) + const message = store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'new direction' } }, + 'persisted-local-id', + options.scheduledAt ?? null + ) + return { engine, session, message } +} + +describe('SyncEngine steerQueuedMessage', () => { + it('rejects unsupported agents before RPC', async () => { + const { engine, session, message } = setup({ flavor: 'claude' }) + + await expect(engine.steerQueuedMessage(session.id, message.id)).resolves.toEqual({ + status: 'failed', + error: 'Steering is not supported for this agent', + localId: null + }) + }) + + it('rejects future-scheduled rows before RPC', async () => { + const { engine, session, message } = setup({ scheduledAt: Date.now() + 60_000 }) + + await expect(engine.steerQueuedMessage(session.id, message.id)).resolves.toEqual({ + status: 'failed', + error: 'Scheduled messages cannot be steered', + localId: 'persisted-local-id' + }) + }) + + it('forwards the persisted local ID to the session RPC', async () => { + const { engine, session, message } = setup() + const calls: Array<{ sessionId: string; localId: string }> = [] + const internals = engine as unknown as EngineInternals + internals.rpcGateway.steerQueuedMessage = async (sessionId, localId) => { + calls.push({ sessionId, localId }) + return { steered: true } + } + + await expect(engine.steerQueuedMessage(session.id, message.id)).resolves.toEqual({ + status: 'steered', + localId: 'persisted-local-id' + }) + expect(calls).toEqual([{ sessionId: session.id, localId: 'persisted-local-id' }]) + }) +}) diff --git a/hub/src/sync/usageService.ts b/hub/src/sync/usageService.ts index 1dac14b4..11d46bba 100644 --- a/hub/src/sync/usageService.ts +++ b/hub/src/sync/usageService.ts @@ -255,6 +255,7 @@ function collectUsageEvents(store: Store, sessions: StoredSession[]): void { if (messages.length > 0 || replaceEvents) { store.usage.recordScan( session.id, + session.namespace, messageEpoch, lastSeq, Array.from(events.values()), @@ -344,8 +345,9 @@ export function getUsageSummary( const now = Date.now() const days = range === '30d' ? 30 : range === 'all' ? null : 7 const from = days === null ? null : now - days * 24 * 60 * 60 * 1000 - const sessionIds = new Set(sessions.map((session) => session.id)) - const events = store.usage.getEvents(Array.from(sessionIds)) + // Read the durable namespace ledger rather than only live session ids; + // usage rows intentionally survive session deletion. + const events = store.usage.getEventsByNamespace(namespace) const isInRange = (event: UsageEvent) => (from === null || event.createdAt >= from) && event.createdAt <= now const totals = emptyTotals() @@ -365,7 +367,9 @@ export function getUsageSummary( let duplicateCumulativeEvent = false if (event.kind === 'cumulative') { const sourceParts = event.sourceKey.split('|') - const streamKey = sourceParts.slice(0, 3).join('|') + // Provider thread ids are only unique within a HAPI session. Keep + // deleted-session history from altering a newer session's delta. + const streamKey = `${event.sessionId}|${sourceParts.slice(0, 3).join('|')}` const previous = cumulativePrevious.get(streamKey) ?? null const current: UsageSnapshot = [ event.inputTokens, diff --git a/hub/src/web/middleware/auth.ts b/hub/src/web/middleware/auth.ts index 175d7d23..e0dbda00 100644 --- a/hub/src/web/middleware/auth.ts +++ b/hub/src/web/middleware/auth.ts @@ -6,18 +6,24 @@ export type WebAppEnv = { Variables: { userId: number namespace: string + sessionId?: string + role?: 'session-guest' + shareToken?: string } } const jwtPayloadSchema = z.object({ uid: z.number(), - ns: z.string() + ns: z.string(), + sid: z.string().min(1).optional(), + role: z.literal('session-guest').optional(), + sht: z.string().min(1).optional() }) -export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler { +export function createAuthMiddleware(jwtSecret: Uint8Array, options?: { isGuestTokenActive?: (shareToken: string) => boolean }): MiddlewareHandler { return async (c, next) => { const path = c.req.path - if (path === '/api/auth' || path === '/api/bind') { + if (path === '/api/auth' || path === '/api/bind' || path.startsWith('/api/public/studios/') || path.startsWith('/api/public/session-shares/')) { await next() return } @@ -40,6 +46,24 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler { + let dataDir: string | null = null + + afterEach(() => { + if (dataDir) { + rmSync(dataDir, { recursive: true, force: true }) + dataDir = null + } + }) + + it('returns trimmed unique model names and drops malformed values', async () => { + dataDir = mkdtempSync(join(tmpdir(), 'hapi-claude-models-test-')) + writeFileSync(join(dataDir, 'settings.json'), JSON.stringify({ + customClaudeModels: [ + ' deepseek-v4-flash[1m] ', + 'deepseek-v4-flash[1m]', + '', + 42, + null + ] + })) + const app = createClaudeModelsRoutes(dataDir) + + const response = await app.request('/claude/custom-models') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ models: ['deepseek-v4-flash[1m]'] }) + }) +}) diff --git a/hub/src/web/routes/claudeModels.ts b/hub/src/web/routes/claudeModels.ts new file mode 100644 index 00000000..01283057 --- /dev/null +++ b/hub/src/web/routes/claudeModels.ts @@ -0,0 +1,26 @@ +import { Hono } from 'hono' +import type { WebAppEnv } from '../middleware/auth' +import { getSettingsFile, readSettings } from '../../config/settings' + +/** + * Custom Claude model names configured in settings.json + * (`customClaudeModels`). Claude Code has no model catalog API like Codex, + * so users routing Claude through a custom ANTHROPIC_BASE_URL list their + * endpoint's model names here to surface them in the New Session picker. + */ +export function createClaudeModelsRoutes(dataDir: string): Hono { + const app = new Hono() + + app.get('/claude/custom-models', async (c) => { + const settings = await readSettings(getSettingsFile(dataDir)) + const models = Array.isArray(settings?.customClaudeModels) + ? settings.customClaudeModels + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) + : [] + return c.json({ models: [...new Set(models)] }) + }) + + return app +} diff --git a/hub/src/web/routes/claudeSessions.test.ts b/hub/src/web/routes/claudeSessions.test.ts new file mode 100644 index 00000000..d9d52891 --- /dev/null +++ b/hub/src/web/routes/claudeSessions.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import type { ClaudeLocalSessionWithMessages } from '@hapi/protocol/apiTypes' +import { Store } from '../../store' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import { importClaudeSession } from './claudeSessions' + +function machine(id = 'machine-1'): Machine { + return { + id, + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: { + host: `${id}.local`, + platform: 'darwin', + happyCliVersion: 'test' + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 1, + health: null + } +} + +function transcript(id: string, prompts: string[]): ClaudeLocalSessionWithMessages { + return { + id, + title: prompts[0] ?? id, + lastUserMessage: prompts.at(-1) ?? null, + cwd: '/tmp/project', + file: `/tmp/${id}.jsonl`, + modifiedAt: prompts.length * 1_000, + model: 'claude-sonnet-4-5', + messageCount: prompts.length, + messages: prompts.map((text, index) => ({ + localId: `claude:${id}:user-${index + 1}`, + createdAt: (index + 1) * 1_000, + content: { + role: 'user', + content: { type: 'text', text }, + meta: { sentFrom: 'cli' } + } + })) + } +} + +function assistantMessage(sessionId: string, uuid: string, text: string, createdAt: number) { + return { + localId: `claude:${sessionId}:${uuid}`, + createdAt, + content: { + role: 'agent' as const, + content: { + type: 'output' as const, + data: { + type: 'assistant', + uuid, + sessionId, + timestamp: new Date(createdAt).toISOString(), + message: { role: 'assistant', content: [{ type: 'text', text }] } + } + }, + meta: { sentFrom: 'cli' as const } + } + } +} + +describe('Claude session import', () => { + const stores: Store[] = [] + + afterEach(() => { + for (const store of stores.splice(0)) store.close() + }) + + function setup() { + const store = new Store(':memory:') + stores.push(store) + const events: unknown[] = [] + const engine = { + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: (event: unknown) => events.push(event) + } as unknown as SyncEngine + return { store, engine, events } + } + + it('imports idempotently and appends new native history', () => { + const { store, engine } = setup() + const first = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: transcript('native-1', ['one']), + launchSettings: { + model: 'deepseek-v4-flash[1m]', + effort: 'high', + permissionMode: 'bypassPermissions' + } + }) + expect(first).toMatchObject({ action: 'created', appended: 1 }) + + const unchanged = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: transcript('native-1', ['one']) + }) + expect(unchanged).toMatchObject({ + hapiSessionId: first.hapiSessionId, + action: 'unchanged', + appended: 0 + }) + + const updated = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: transcript('native-1', ['one', 'two']) + }) + expect(updated).toMatchObject({ + hapiSessionId: first.hapiSessionId, + action: 'updated', + appended: 1 + }) + expect(store.messages.getAllMessages(first.hapiSessionId!)).toHaveLength(2) + expect(store.sessions.getSession(first.hapiSessionId!)?.metadata).toMatchObject({ + flavor: 'claude', + claudeSessionId: 'native-1', + lifecycleState: 'archived', + preferredPermissionMode: 'bypassPermissions', + claudeImportState: { state: 'complete' } + }) + expect(store.sessions.getSession(first.hapiSessionId!)).toMatchObject({ + model: 'deepseek-v4-flash[1m]', + effort: 'high' + }) + }) + + it('does not duplicate native entries already observed by the live HAPI session', () => { + const { store, engine } = setup() + const sessionId = 'native-live' + const initialTranscript = transcript(sessionId, ['one']) + initialTranscript.messages.push(assistantMessage(sessionId, 'assistant-1', 'first answer', 1_500)) + initialTranscript.messageCount = initialTranscript.messages.length + const initial = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: initialTranscript + }) + + const expandedTranscript = transcript(sessionId, ['one', 'two']) + expandedTranscript.messages.splice(1, 0, assistantMessage(sessionId, 'assistant-1', 'first answer', 1_500)) + expandedTranscript.messages.push(assistantMessage(sessionId, 'assistant-2', 'second answer', 2_500)) + expandedTranscript.messageCount = expandedTranscript.messages.length + const liveUser = expandedTranscript.messages[2]! + const liveAssistant = expandedTranscript.messages[3]! + store.messages.addMessage(initial.hapiSessionId!, liveUser.content, 'web-user-2') + store.messages.addMessage(initial.hapiSessionId!, liveAssistant.content) + + const repeated = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: expandedTranscript + }) + + expect(repeated).toMatchObject({ action: 'unchanged', appended: 0 }) + expect(store.messages.getAllMessages(initial.hapiSessionId!)).toHaveLength(4) + }) + + it('reuses a normal HAPI session with the same Claude session id', () => { + const { store, engine } = setup() + const existing = store.sessions.getOrCreateSession( + 'existing-claude', + { + path: '/tmp/project', + host: 'machine-1.local', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'native-1' + }, + {}, + 'default', + 'claude-haiku-4-5', + 'low' + ) + + const result = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: transcript('native-1', ['already observed']), + launchSettings: { + model: 'claude-opus-4-1', + effort: 'high', + permissionMode: 'bypassPermissions' + } + }) + + expect(result).toMatchObject({ + hapiSessionId: existing.id, + action: 'unchanged', + appended: 0 + }) + expect(store.sessions.getSessionsByNamespace('default')).toHaveLength(1) + expect(store.messages.getAllMessages(existing.id)).toHaveLength(0) + expect(store.sessions.getSession(existing.id)).toMatchObject({ + model: 'claude-opus-4-1', + effort: 'high', + metadata: expect.objectContaining({ preferredPermissionMode: 'bypassPermissions' }) + }) + + importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: transcript('native-1', ['already observed']), + launchSettings: { model: null, effort: null } + }) + expect(store.sessions.getSession(existing.id)).toMatchObject({ model: null, effort: null }) + }) + + it('marks rewritten imported history as diverged', () => { + const { store, engine } = setup() + const initial = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: transcript('native-1', ['one']) + }) + const rewritten = importClaudeSession({ + store, + engine, + namespace: 'default', + machine: machine(), + transcript: transcript('native-1', ['changed']) + }) + + expect(rewritten.error?.code).toBe('transcript_diverged') + const metadata = store.sessions.getSession(initial.hapiSessionId!)?.metadata as { claudeImportState?: { state?: string } } | undefined + expect(metadata?.claudeImportState?.state).toBe('diverged') + }) +}) diff --git a/hub/src/web/routes/claudeSessions.ts b/hub/src/web/routes/claudeSessions.ts new file mode 100644 index 00000000..28678ca0 --- /dev/null +++ b/hub/src/web/routes/claudeSessions.ts @@ -0,0 +1,567 @@ +import { dirname } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { Hono } from 'hono' +import type { ClaudeLocalSessionSummary, ClaudeLocalSessionWithMessages } from '@hapi/protocol/apiTypes' +import type { Metadata } from '@hapi/protocol/types' +import type { Store, StoredMessage, StoredSession } from '../../store' +import { ImportedMessageConflictError } from '../../store/messages' +import { truncateOversizedMessageContent } from '../../store/contentCodec' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' + +const importLocks = new Map>() + +export type ClaudeSessionListItem = ClaudeLocalSessionSummary & { + hapiSessionId?: string + importState?: 'importing' | 'complete' | 'failed' | 'diverged' +} + +export type ClaudeImportResult = { + claudeSessionId: string + hapiSessionId?: string + action?: 'created' | 'updated' | 'unchanged' + appended?: number + error?: { code: string; message: string } +} + +type ClaudeImportLaunchSettings = { + model?: string | null + effort?: string | null + permissionMode?: 'default' | 'bypassPermissions' +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null +} + +function parseLaunchSettings(body: Record): ClaudeImportLaunchSettings | null { + const settings: ClaudeImportLaunchSettings = {} + for (const key of ['model', 'effort'] as const) { + if (!Object.prototype.hasOwnProperty.call(body, key)) continue + const value = body[key] + if (value !== null && typeof value !== 'string') return null + settings[key] = typeof value === 'string' ? value.trim() || null : null + } + if (Object.prototype.hasOwnProperty.call(body, 'permissionMode')) { + if (body.permissionMode !== 'default' && body.permissionMode !== 'bypassPermissions') return null + settings.permissionMode = body.permissionMode + } + return settings +} + +function storedMetadata(session: StoredSession): Record { + return asRecord(session.metadata) ?? {} +} + +function resolveClaudeMachine(engine: SyncEngine | null, namespace: string, requestedMachineId?: string | null): Machine | null { + if (!engine) return null + const online = engine.getOnlineMachinesByNamespace(namespace) + if (requestedMachineId) return online.find((machine) => machine.id === requestedMachineId) ?? null + return online[0] ?? null +} + +function importedClaudeSessionsById(store: Store, namespace: string, machineId: string): Map { + const imported = new Map() + for (const session of store.sessions.getSessionsByNamespace(namespace)) { + const metadata = storedMetadata(session) + const claudeSessionId = metadata.claudeSessionId + if ( + metadata.flavor !== 'claude' || + metadata.machineId !== machineId || + typeof claudeSessionId !== 'string' || + imported.has(claudeSessionId) + ) + continue + imported.set(claudeSessionId, session) + } + return imported +} + +function buildClaudeMetadata( + transcript: ClaudeLocalSessionWithMessages, + machine: Machine, + existing: Record, + state: NonNullable, + launchSettings: ClaudeImportLaunchSettings +): Metadata { + const summaryText = transcript.lastUserMessage ?? transcript.title + return { + ...existing, + path: transcript.cwd ?? (typeof existing.path === 'string' ? existing.path : dirname(transcript.file)), + host: typeof existing.host === 'string' ? existing.host : (machine.metadata?.host ?? machine.id), + os: typeof existing.os === 'string' ? existing.os : (machine.metadata?.platform ?? process.platform), + name: typeof existing.name === 'string' ? existing.name : transcript.title, + summary: summaryText ? { text: summaryText, updatedAt: Date.now() } : undefined, + machineId: machine.id, + flavor: 'claude', + claudeSessionId: transcript.id, + lifecycleState: typeof existing.lifecycleState === 'string' ? existing.lifecycleState : 'archived', + lifecycleStateSince: typeof existing.lifecycleStateSince === 'number' ? existing.lifecycleStateSince : Date.now(), + archivedBy: typeof existing.archivedBy === 'string' ? existing.archivedBy : 'claude-import', + archiveReason: typeof existing.archiveReason === 'string' ? existing.archiveReason : 'Imported from local Claude history', + ...(launchSettings.permissionMode !== undefined + ? { preferredPermissionMode: launchSettings.permissionMode } + : {}), + claudeImportState: state + } +} + +function updateMetadataWithRetry( + store: Store, + sessionId: string, + namespace: string, + transform: (metadata: Record) => Metadata +): Metadata { + for (let attempt = 0; attempt < 5; attempt += 1) { + const current = store.sessions.getSessionByNamespace(sessionId, namespace) + if (!current) throw new Error('Imported HAPI session disappeared') + const next = transform(storedMetadata(current)) + const result = store.sessions.updateSessionMetadata(sessionId, next, current.metadataVersion, namespace, { touchUpdatedAt: false }) + if (result.result === 'success') return next + if (result.result === 'error') throw new Error('Failed to persist Claude import metadata') + } + throw new Error('Claude import metadata changed concurrently') +} + +function applyClaudeLaunchSettings( + store: Store, + sessionId: string, + namespace: string, + launchSettings: ClaudeImportLaunchSettings +): void { + if (launchSettings.permissionMode !== undefined) { + updateMetadataWithRetry( + store, + sessionId, + namespace, + (metadata) => ({ ...metadata, preferredPermissionMode: launchSettings.permissionMode }) as Metadata + ) + } + if (launchSettings.model !== undefined) { + store.sessions.setSessionModel(sessionId, launchSettings.model, namespace, { touchUpdatedAt: false }) + } + if (launchSettings.effort !== undefined) { + store.sessions.setSessionEffort(sessionId, launchSettings.effort, namespace, { touchUpdatedAt: false }) + } +} + +function emitImportedMessages(engine: SyncEngine, sessionId: string, messages: StoredMessage[]): void { + for (const message of messages) { + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId, + message: { + id: message.id, + seq: message.seq, + localId: message.localId, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt + } + }) + } +} + +function importedPrefix(sessionId: string): string { + return `claude:${sessionId}:` +} + +function nativeClaudeLocalId(message: StoredMessage, sessionId: string): string | null { + const envelope = asRecord(message.content) + const output = asRecord(envelope?.content) + const event = asRecord(output?.data) + if (envelope?.role !== 'agent' || output?.type !== 'output' || typeof event?.uuid !== 'string') return null + return `${importedPrefix(sessionId)}${event.uuid}` +} + +function classifyImportDelta( + existing: StoredMessage[], + transcript: ClaudeLocalSessionWithMessages +): { messages: ClaudeLocalSessionWithMessages['messages']; error?: string } { + const sourceIndexByLocalId = new Map(transcript.messages.map((message, index) => [message.localId, index])) + const storedImported = existing.filter((message) => message.localId?.startsWith(importedPrefix(transcript.id))) + let priorSourceIndex = -1 + for (const message of storedImported) { + const sourceIndex = sourceIndexByLocalId.get(message.localId!) + if (sourceIndex === undefined || sourceIndex <= priorSourceIndex) { + return { + messages: [], + error: 'Local Claude transcript no longer extends the previously imported history' + } + } + priorSourceIndex = sourceIndex + } + + const sourceByLocalId = new Map( + transcript.messages.map((message) => [message.localId, truncateOversizedMessageContent(message.content)]) + ) + const changed = storedImported.find((message) => !isDeepStrictEqual(sourceByLocalId.get(message.localId!), message.content)) + if (changed?.localId) { + return { + messages: [], + error: `Local Claude transcript changed imported entry ${changed.localId}` + } + } + + const imported = new Set(storedImported.map((message) => message.localId!)) + let observedSourceIndex = priorSourceIndex + for (const message of existing) { + const localId = message.localId?.startsWith(importedPrefix(transcript.id)) + ? message.localId + : nativeClaudeLocalId(message, transcript.id) + if (!localId) continue + const sourceIndex = sourceIndexByLocalId.get(localId) + if (sourceIndex !== undefined) observedSourceIndex = Math.max(observedSourceIndex, sourceIndex) + } + return { + messages: transcript.messages.filter((message, index) => index > observedSourceIndex && !imported.has(message.localId)) + } +} + +function markImportState( + store: Store, + engine: SyncEngine, + sessionId: string, + namespace: string, + transcript: ClaudeLocalSessionWithMessages, + machineId: string, + state: 'failed' | 'diverged', + error: string +): void { + const current = store.sessions.getSessionByNamespace(sessionId, namespace) + const currentState = asRecord(asRecord(current?.metadata)?.claudeImportState) + const startedAt = typeof currentState?.startedAt === 'number' ? currentState.startedAt : Date.now() + updateMetadataWithRetry( + store, + sessionId, + namespace, + (metadata) => + ({ + ...metadata, + path: typeof metadata.path === 'string' ? metadata.path : (transcript.cwd ?? dirname(transcript.file)), + host: typeof metadata.host === 'string' ? metadata.host : machineId, + claudeImportState: { + state, + machineId, + claudeSessionId: transcript.id, + sourceFile: transcript.file, + startedAt, + updatedAt: Date.now(), + error + } + }) as Metadata + ) + engine.handleRealtimeEvent({ type: 'session-updated', sessionId }) +} + +export function importClaudeSession(options: { + store: Store + engine: SyncEngine + namespace: string + machine: Machine + transcript: ClaudeLocalSessionWithMessages + existingSession?: StoredSession | null + launchSettings?: ClaudeImportLaunchSettings +}): ClaudeImportResult { + const { store, engine, namespace, machine, transcript, existingSession } = options + const launchSettings = options.launchSettings ?? {} + const startedAt = Date.now() + let stored = + existingSession === undefined + ? (importedClaudeSessionsById(store, namespace, machine.id).get(transcript.id) ?? null) + : existingSession + + // A normal HAPI-created Claude row already contains the history it observed live. + // Reuse it instead of duplicating the same native conversation into an import row. + if (stored && !asRecord(stored.metadata)?.claudeImportState) { + applyClaudeLaunchSettings(store, stored.id, namespace, launchSettings) + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: stored.id }) + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + action: 'unchanged', + appended: 0 + } + } + + const created = !stored + if (!stored) { + const metadata = buildClaudeMetadata( + transcript, + machine, + {}, + { + state: 'importing', + machineId: machine.id, + claudeSessionId: transcript.id, + sourceFile: transcript.file, + startedAt, + updatedAt: startedAt + }, + launchSettings + ) + const initialModel = launchSettings.model !== undefined ? launchSettings.model : transcript.model + stored = store.sessions.getOrCreateSession( + `claude-import:${machine.id}:${transcript.id}`, + metadata, + {}, + namespace, + initialModel ?? undefined, + launchSettings.effort ?? undefined + ) + } else { + const existingDelta = classifyImportDelta(store.messages.getAllMessages(stored.id), transcript) + if (existingDelta.error) { + markImportState(store, engine, stored.id, namespace, transcript, machine.id, 'diverged', existingDelta.error) + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: 'transcript_diverged', message: existingDelta.error } + } + } + if (stored.active) { + if (existingDelta.messages.length > 0) { + const message = 'The HAPI Claude session is active; stop it before importing native history changes' + markImportState(store, engine, stored.id, namespace, transcript, machine.id, 'failed', message) + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: 'session_active', message } + } + } + applyClaudeLaunchSettings(store, stored.id, namespace, launchSettings) + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: stored.id }) + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + action: 'unchanged', + appended: 0 + } + } + updateMetadataWithRetry(store, stored.id, namespace, (metadata) => + buildClaudeMetadata( + transcript, + machine, + metadata, + { + state: 'importing', + machineId: machine.id, + claudeSessionId: transcript.id, + sourceFile: transcript.file, + startedAt, + updatedAt: startedAt + }, + launchSettings + ) + ) + } + + const delta = classifyImportDelta(store.messages.getAllMessages(stored.id), transcript) + if (delta.error) { + markImportState(store, engine, stored.id, namespace, transcript, machine.id, 'diverged', delta.error) + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: 'transcript_diverged', message: delta.error } + } + } + const appended: StoredMessage[] = [] + try { + for (const source of delta.messages) { + const result = store.messages.addImportedMessage(stored.id, source.content, source.localId, source.createdAt) + if (result.inserted) appended.push(result.message) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to persist imported Claude history' + const state = error instanceof ImportedMessageConflictError ? 'diverged' : 'failed' + markImportState(store, engine, stored.id, namespace, transcript, machine.id, state, message) + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + error: { + code: state === 'diverged' ? 'transcript_diverged' : 'import_failed', + message + } + } + } + + try { + updateMetadataWithRetry(store, stored.id, namespace, (metadata) => + buildClaudeMetadata( + transcript, + machine, + metadata, + { + state: 'complete', + machineId: machine.id, + claudeSessionId: transcript.id, + sourceFile: transcript.file, + startedAt, + updatedAt: Date.now() + }, + launchSettings + ) + ) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to finalize imported Claude history' + try { + markImportState(store, engine, stored.id, namespace, transcript, machine.id, 'failed', message) + } catch {} + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: 'import_failed', message } + } + } + + const resolvedModel = launchSettings.model !== undefined ? launchSettings.model : created ? transcript.model : undefined + if (resolvedModel !== undefined) { + store.sessions.setSessionModel(stored.id, resolvedModel ?? null, namespace, { touchUpdatedAt: false }) + } + if (launchSettings.effort !== undefined) { + store.sessions.setSessionEffort(stored.id, launchSettings.effort, namespace, { touchUpdatedAt: false }) + } + const activityAt = appended.at(-1)?.createdAt ?? transcript.modifiedAt + engine.recordSessionActivity(stored.id, activityAt) + emitImportedMessages(engine, stored.id, appended) + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: stored.id }) + return { + claudeSessionId: transcript.id, + hapiSessionId: stored.id, + action: created ? 'created' : appended.length > 0 ? 'updated' : 'unchanged', + appended: appended.length + } +} + +async function importWithLock(key: string, work: () => ClaudeImportResult): Promise { + const prior = importLocks.get(key) + if (prior) return prior + const current = Promise.resolve().then(work) + importLocks.set(key, current) + try { + return await current + } finally { + if (importLocks.get(key) === current) importLocks.delete(key) + } +} + +export function createClaudeSessionRoutes(options: { store: Store; getSyncEngine: () => SyncEngine | null }): Hono { + const app = new Hono() + + app.get('/claude/sessions', async (c) => { + const namespace = c.get('namespace') + const engine = options.getSyncEngine() + const machine = resolveClaudeMachine(engine, namespace, c.req.query('machineId')?.trim() || null) + if (!engine || !machine) + return c.json( + { + success: false, + error: 'No online machine available for Claude history import', + sessions: [] + }, + 503 + ) + const result = await engine.listClaudeSessionsForMachine(machine.id, c.req.query('cwd')?.trim() || null) + if (!result.success) + return c.json( + { + success: false, + error: result.error, + sessions: [], + machineId: machine.id + }, + 503 + ) + const importedByClaudeId = importedClaudeSessionsById(options.store, namespace, machine.id) + const sessions: ClaudeSessionListItem[] = result.sessions.map((summary) => { + const imported = importedByClaudeId.get(summary.id) + const state = asRecord(asRecord(imported?.metadata)?.claudeImportState)?.state + return { + ...summary, + ...(imported ? { hapiSessionId: imported.id } : {}), + ...(state === 'importing' || state === 'complete' || state === 'failed' || state === 'diverged' + ? { importState: state } + : {}) + } + }) + return c.json({ success: true, sessions, machineId: machine.id }) + }) + + app.post('/claude/import-sessions', async (c) => { + const body = asRecord(await c.req.json().catch(() => null)) + const sessionIds = Array.isArray(body?.sessionIds) + ? body.sessionIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0).map((id) => id.trim()) + : [] + if (sessionIds.length === 0) return c.json({ success: false, error: 'No Claude sessions selected', results: [] }, 400) + const launchSettings = parseLaunchSettings(body ?? {}) + if (!launchSettings) return c.json({ success: false, error: 'Invalid Claude launch settings', results: [] }, 400) + const uniqueSessionIds = [...new Set(sessionIds)] + const namespace = c.get('namespace') + const engine = options.getSyncEngine() + const machine = resolveClaudeMachine(engine, namespace, typeof body?.machineId === 'string' ? body.machineId.trim() : null) + if (!engine || !machine) + return c.json( + { + success: false, + error: 'No online machine available for Claude history import', + results: [] + }, + 503 + ) + const remote = await engine.listClaudeSessionsForMachine( + machine.id, + typeof body?.cwd === 'string' ? body.cwd.trim() : null, + uniqueSessionIds + ) + if (!remote.success) + return c.json( + { + success: false, + error: remote.error, + results: [], + machineId: machine.id + }, + 503 + ) + const byId = new Map( + remote.sessions + .filter((session): session is ClaudeLocalSessionWithMessages => 'messages' in session) + .map((session) => [session.id, session]) + ) + const importedByClaudeId = importedClaudeSessionsById(options.store, namespace, machine.id) + const results: ClaudeImportResult[] = [] + for (const sessionId of uniqueSessionIds) { + const transcript = byId.get(sessionId) + if (!transcript) { + results.push({ + claudeSessionId: sessionId, + error: { + code: 'not_found', + message: 'Claude session transcript not found' + } + }) + continue + } + results.push( + await importWithLock(`${namespace}:${machine.id}:${sessionId}`, () => + importClaudeSession({ + store: options.store, + engine, + namespace, + machine, + transcript, + existingSession: importedByClaudeId.get(sessionId) ?? null, + launchSettings + }) + ) + ) + } + return c.json({ + success: results.every((result) => !result.error), + results, + machineId: machine.id + }) + }) + + return app +} diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts index ddfbd3f4..f47f1abf 100644 --- a/hub/src/web/routes/codexDesktop.test.ts +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -1040,14 +1040,83 @@ describe('Codex Desktop import routes', () => { } }) - it('rejects Codex transcript endpoints outside the default namespace', async () => { + it('imports Codex transcripts from the current namespace Runner', async () => { + const store = new Store(':memory:') + const machine = createMachine('team-a-machine', ['/workspace'], 'team-a') + const codexSessionId = '16161616-1616-4616-8616-161616161616' + const requestedNamespaces: string[] = [] + const engine = { + getOnlineMachinesByNamespace: (namespace: string) => { + requestedNamespaces.push(namespace) + return namespace === 'team-a' ? [machine] : [] + }, + listCodexSessionsForMachine: async (machineId: string) => { + expect(machineId).toBe(machine.id) + return { + success: true, + sessions: [{ + id: codexSessionId, + title: 'Team A transcript', + cwd: '/workspace/project', + file: '/home/team-a/.codex/sessions/transcript.jsonl', + modifiedAt: 1, + messages: [{ + role: 'user', + content: { type: 'text', text: 'team-a message' }, + meta: { sentFrom: 'cli' } + }] + }] + } + }, + getSessionsByNamespace: (namespace: string) => store.sessions.getSessionsByNamespace(namespace), + getOrCreateSession: ( + tag: string, + metadata: unknown, + agentState: unknown, + namespace: string + ) => store.sessions.getOrCreateSession(tag, metadata, agentState, namespace), + handleRealtimeEvent: () => {}, + recordSessionActivity: () => {} + } as unknown as SyncEngine + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'team-a') + await next() + }) + app.route('/api', createCodexDesktopRoutes({ store, getSyncEngine: () => engine })) + + try { + const listResponse = await app.request(`/api/codex/sessions?machineId=${machine.id}`) + expect(listResponse.status).toBe(200) + expect(await listResponse.json()).toMatchObject({ + success: true, + machineId: machine.id, + sessions: [{ id: codexSessionId, title: 'Team A transcript' }] + }) + + const importResponse = await app.request('/api/codex/sync-session', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: [codexSessionId], machineId: machine.id }) + }) + expect(importResponse.status).toBe(200) + expect(await importResponse.json()).toMatchObject({ success: true, syncedCount: 1 }) + expect(store.sessions.getSessionsByNamespace('team-a')).toHaveLength(1) + expect(store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + expect(requestedNamespaces.every((namespace) => namespace === 'team-a')).toBe(true) + } finally { + store.close() + } + }) + + it('keeps Hub-local Codex Desktop control restricted to the default namespace', async () => { const app = createRoutesApp('team-a') - const response = await app.request('/api/codex/sessions') + const response = await app.request('/api/codex/restart-desktop', { method: 'POST' }) expect(response.status).toBe(403) expect(await response.json()).toEqual({ success: false, - error: 'Codex transcript import is not available outside the default namespace' + error: 'Codex Desktop control is not available outside the default namespace' }) }) diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index fb54b67f..f1466cde 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -171,7 +171,7 @@ type DuplicateSessionGroupCandidate = { const CODEX_DESKTOP_NOT_FOUND_ERROR = '尝试重启codex客户端失败,未安装/找不到codex客户端' const SCRIPT_TIMEOUT_ERROR = '执行超时' const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的 Codex 会话' -const CODEX_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Codex transcript import is not available outside the default namespace' +const CODEX_DESKTOP_CONTROL_NAMESPACE_ERROR = 'Codex Desktop control is not available outside the default namespace' const DEFAULT_SCRIPT_TIMEOUT_MS = 60_000 const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 500 const DARWIN_CODEX_APP_NAME = 'Codex' @@ -1335,6 +1335,9 @@ async function mergeSingleDuplicateCodexSessionGroup(options: { latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) } + // Preserve indexed token history when deduplication removes a source + // session. The usage ledger is independent of the session row. + options.store.usage.transferSession(source.sessionId, canonical.sessionId) if (engine) { await engine.deleteSession(source.sessionId) } else { @@ -2191,16 +2194,6 @@ export function createCodexDesktopRoutes(options: { }): Hono { const app = new Hono() - app.use('/codex/*', async (c, next) => { - if (c.get('namespace') !== 'default') { - return c.json({ - success: false, - error: CODEX_TRANSCRIPT_IMPORT_NAMESPACE_ERROR - }, 403) - } - return next() - }) - app.get('/codex/status', (c) => { const codexStatus = getCodexDesktopStatus() return c.json({ @@ -2398,6 +2391,13 @@ export function createCodexDesktopRoutes(options: { }) app.post('/codex/restart-desktop', async (c) => { + if (c.get('namespace') !== 'default') { + return c.json({ + success: false, + error: CODEX_DESKTOP_CONTROL_NAMESPACE_ERROR + }, 403) + } + const codexStatus = getCodexDesktopStatus() if (!codexStatus.clientAvailable) { const scriptPath = getRestartScriptPath() diff --git a/hub/src/web/routes/dshSessions.test.ts b/hub/src/web/routes/dshSessions.test.ts new file mode 100644 index 00000000..6ce3e13e --- /dev/null +++ b/hub/src/web/routes/dshSessions.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import type { DshImportedMessage, DshLocalSessionWithMessages } from '@hapi/protocol/apiTypes' +import { Store } from '../../store' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import { importDshSession } from './dshSessions' + +function machine(id: string): Machine { + return { + id, + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: { host: `${id}.local`, platform: 'darwin', happyCliVersion: 'test', homeDir: '/tmp' }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 1, + health: null + } +} + +function userMessage(sessionId: string, eventSeq: number, text: string): DshImportedMessage { + return { + localId: `dsh:${sessionId}:${eventSeq}:user`, + eventSeq, + createdAt: 1_000 + eventSeq, + content: { + role: 'user', + content: { type: 'text', text }, + meta: { sentFrom: 'cli' } + } + } +} + +function transcript(sessionId: string, messages: DshImportedMessage[]): DshLocalSessionWithMessages { + const lastUser = [...messages].reverse().find((message) => message.content.role === 'user') + return { + id: sessionId, + title: `Session ${sessionId}`, + lastUserMessage: lastUser?.content.role === 'user' && lastUser.content.content.type === 'text' + ? lastUser.content.content.text + : null, + cwd: '/tmp/project', + modifiedAt: messages.at(-1)?.createdAt ?? 1, + model: 'deepseek-v4-pro', + reasoningEffort: 'max', + messageCount: messages.length, + running: false, + parentSessionId: null, + messages, + lastEventSeq: messages.at(-1)?.eventSeq ?? null + } +} + +describe('DeepSeek Harness session import', () => { + const stores: Store[] = [] + + afterEach(() => { + for (const store of stores.splice(0)) store.close() + }) + + function setup() { + const store = new Store(':memory:') + stores.push(store) + const events: unknown[] = [] + const engine = { + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: (event: unknown) => events.push(event) + } as unknown as SyncEngine + return { store, engine, events } + } + + it('is idempotent, appends by native event sequence, and blocks active updates', () => { + const { store, engine } = setup() + const selectedMachine = machine('machine-1') + const firstTranscript = transcript('native-1', [ + userMessage('native-1', 1, 'first'), + userMessage('native-1', 2, 'second') + ]) + const first = importDshSession({ + store, + engine, + namespace: 'default', + machine: selectedMachine, + sourceUrl: 'http://127.0.0.1:3080', + transcript: firstTranscript + }) + expect(first).toMatchObject({ action: 'created', appended: 2 }) + + const unchanged = importDshSession({ + store, + engine, + namespace: 'default', + machine: selectedMachine, + sourceUrl: 'http://127.0.0.1:3080', + transcript: firstTranscript + }) + expect(unchanged).toMatchObject({ hapiSessionId: first.hapiSessionId, action: 'unchanged', appended: 0 }) + + const extended = transcript('native-1', [ + ...firstTranscript.messages, + userMessage('native-1', 3, 'third') + ]) + const updated = importDshSession({ + store, + engine, + namespace: 'default', + machine: selectedMachine, + sourceUrl: 'http://127.0.0.1:3080', + transcript: extended + }) + expect(updated).toMatchObject({ hapiSessionId: first.hapiSessionId, action: 'updated', appended: 1 }) + expect(store.messages.getAllMessages(first.hapiSessionId!)).toHaveLength(3) + + store.sessions.setSessionActive(first.hapiSessionId!, true, 2_000, 'default') + const activeResult = importDshSession({ + store, + engine, + namespace: 'default', + machine: selectedMachine, + sourceUrl: 'http://127.0.0.1:3080', + transcript: transcript('native-1', [...extended.messages, userMessage('native-1', 4, 'fourth')]) + }) + expect(activeResult.error?.code).toBe('session_active') + expect(store.messages.getAllMessages(first.hapiSessionId!)).toHaveLength(3) + const stored = store.sessions.getSession(first.hapiSessionId!)! + expect(stored.model).toBe('deepseek-v4-pro') + expect(stored.modelReasoningEffort).toBe('max') + expect((stored.metadata as { dshHistoryLastEventSeq?: number }).dshHistoryLastEventSeq).toBe(3) + }) + + it('scopes the same native id by machine', () => { + const { store, engine } = setup() + const source = transcript('same-native-id', [userMessage('same-native-id', 1, 'hello')]) + const first = importDshSession({ + store, engine, namespace: 'default', machine: machine('machine-1'), + sourceUrl: 'http://127.0.0.1:3080', transcript: source + }) + const second = importDshSession({ + store, engine, namespace: 'default', machine: machine('machine-2'), + sourceUrl: 'http://127.0.0.1:3080', transcript: source + }) + expect(first.hapiSessionId).not.toBe(second.hapiSessionId) + }) +}) diff --git a/hub/src/web/routes/dshSessions.ts b/hub/src/web/routes/dshSessions.ts new file mode 100644 index 00000000..4dc5c7e7 --- /dev/null +++ b/hub/src/web/routes/dshSessions.ts @@ -0,0 +1,436 @@ +import { isDeepStrictEqual } from 'node:util' +import { Hono } from 'hono' +import type { DshLocalSessionSummary, DshLocalSessionWithMessages } from '@hapi/protocol/apiTypes' +import type { Metadata } from '@hapi/protocol/types' +import type { Store, StoredMessage, StoredSession } from '../../store' +import { ImportedMessageConflictError } from '../../store/messages' +import { truncateOversizedMessageContent } from '../../store/contentCodec' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' + +const importLocks = new Map>() + +export type DshSessionListItem = DshLocalSessionSummary & { + hapiSessionId?: string + importState?: 'importing' | 'complete' | 'failed' | 'diverged' +} + +export type DshImportResult = { + dshSessionId: string + hapiSessionId?: string + action?: 'created' | 'updated' | 'unchanged' + appended?: number + error?: { code: string; message: string } +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function storedMetadata(session: StoredSession): Record { + return asRecord(session.metadata) ?? {} +} + +function resolveDshMachine( + engine: SyncEngine | null, + namespace: string, + requestedMachineId?: string | null +): Machine | null { + if (!engine) return null + const online = engine.getOnlineMachinesByNamespace(namespace) + if (requestedMachineId) return online.find((machine) => machine.id === requestedMachineId) ?? null + return online[0] ?? null +} + +function importedDshSessionsById( + store: Store, + namespace: string, + machineId: string +): Map { + const imported = new Map() + for (const session of store.sessions.getSessionsByNamespace(namespace)) { + const metadata = storedMetadata(session) + const dshSessionId = metadata.dshSessionId + if (metadata.flavor !== 'dsh' + || metadata.machineId !== machineId + || typeof dshSessionId !== 'string' + || imported.has(dshSessionId)) continue + imported.set(dshSessionId, session) + } + return imported +} + +function buildDshMetadata( + transcript: DshLocalSessionWithMessages, + machine: Machine, + sourceUrl: string, + existing: Record, + state: NonNullable +): Metadata { + const summaryText = transcript.lastUserMessage ?? transcript.title + return { + ...existing, + path: transcript.cwd + ?? (typeof existing.path === 'string' ? existing.path : machine.metadata?.homeDir ?? process.cwd()), + host: typeof existing.host === 'string' ? existing.host : (machine.metadata?.host ?? machine.id), + os: typeof existing.os === 'string' ? existing.os : (machine.metadata?.platform ?? process.platform), + name: typeof existing.name === 'string' ? existing.name : transcript.title, + summary: summaryText ? { text: summaryText, updatedAt: Date.now() } : undefined, + machineId: machine.id, + flavor: 'dsh', + dshSessionId: transcript.id, + lifecycleState: typeof existing.lifecycleState === 'string' ? existing.lifecycleState : 'archived', + lifecycleStateSince: typeof existing.lifecycleStateSince === 'number' ? existing.lifecycleStateSince : Date.now(), + archivedBy: typeof existing.archivedBy === 'string' ? existing.archivedBy : 'dsh-import', + archiveReason: typeof existing.archiveReason === 'string' + ? existing.archiveReason + : 'Imported from DeepSeek Harness history', + dshHistoryLastEventSeq: state.state === 'complete' + ? transcript.lastEventSeq ?? undefined + : typeof existing.dshHistoryLastEventSeq === 'number' + ? existing.dshHistoryLastEventSeq + : undefined, + dshImportState: { ...state, sourceUrl } + } +} + +function updateMetadataWithRetry( + store: Store, + sessionId: string, + namespace: string, + transform: (metadata: Record) => Metadata +): Metadata { + for (let attempt = 0; attempt < 5; attempt += 1) { + const current = store.sessions.getSessionByNamespace(sessionId, namespace) + if (!current) throw new Error('Imported HAPI session disappeared') + const next = transform(storedMetadata(current)) + const result = store.sessions.updateSessionMetadata( + sessionId, + next, + current.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') return next + if (result.result === 'error') throw new Error('Failed to persist DeepSeek Harness import metadata') + } + throw new Error('DeepSeek Harness import metadata changed concurrently') +} + +function emitImportedMessages(engine: SyncEngine, sessionId: string, messages: StoredMessage[]): void { + for (const message of messages) { + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId, + message: { + id: message.id, + seq: message.seq, + localId: message.localId, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt + } + }) + } +} + +function importedPrefix(sessionId: string): string { + return `dsh:${sessionId}:` +} + +function classifyImportDelta( + existing: StoredMessage[], + transcript: DshLocalSessionWithMessages, + observedEventSeq: number | null +): { messages: DshLocalSessionWithMessages['messages']; error?: string } { + const sourceIndexByLocalId = new Map(transcript.messages.map((message, index) => [message.localId, index])) + const storedImported = existing.filter((message) => message.localId?.startsWith(importedPrefix(transcript.id))) + let priorSourceIndex = -1 + for (const message of storedImported) { + const sourceIndex = sourceIndexByLocalId.get(message.localId!) + if (sourceIndex === undefined || sourceIndex <= priorSourceIndex) { + return { messages: [], error: 'DeepSeek Harness history no longer extends the previously imported transcript' } + } + priorSourceIndex = sourceIndex + } + + const sourceByLocalId = new Map(transcript.messages.map((message) => [ + message.localId, + truncateOversizedMessageContent(message.content) + ])) + const changed = storedImported.find((message) => !isDeepStrictEqual(sourceByLocalId.get(message.localId!), message.content)) + if (changed?.localId) { + return { messages: [], error: `DeepSeek Harness history changed imported event ${changed.localId}` } + } + + const importedIds = new Set(storedImported.map((message) => message.localId!)) + return { + messages: transcript.messages.filter((message) => + !importedIds.has(message.localId) + && (observedEventSeq === null || message.eventSeq > observedEventSeq) + ) + } +} + +function markImportState(options: { + store: Store + engine: SyncEngine + sessionId: string + namespace: string + transcript: DshLocalSessionWithMessages + machineId: string + sourceUrl: string + state: 'failed' | 'diverged' + error: string +}): void { + const current = options.store.sessions.getSessionByNamespace(options.sessionId, options.namespace) + const currentState = asRecord(asRecord(current?.metadata)?.dshImportState) + const startedAt = typeof currentState?.startedAt === 'number' ? currentState.startedAt : Date.now() + updateMetadataWithRetry(options.store, options.sessionId, options.namespace, (metadata) => ({ + ...metadata, + path: typeof metadata.path === 'string' ? metadata.path : (options.transcript.cwd ?? process.cwd()), + host: typeof metadata.host === 'string' ? metadata.host : options.machineId, + dshImportState: { + state: options.state, + machineId: options.machineId, + dshSessionId: options.transcript.id, + sourceUrl: options.sourceUrl, + startedAt, + updatedAt: Date.now(), + lastEventSeq: options.transcript.lastEventSeq, + error: options.error + } + } as Metadata)) + options.engine.handleRealtimeEvent({ type: 'session-updated', sessionId: options.sessionId }) +} + +export function importDshSession(options: { + store: Store + engine: SyncEngine + namespace: string + machine: Machine + sourceUrl: string + transcript: DshLocalSessionWithMessages + existingSession?: StoredSession | null +}): DshImportResult { + const { store, engine, namespace, machine, sourceUrl, transcript, existingSession } = options + const startedAt = Date.now() + let stored = existingSession === undefined + ? importedDshSessionsById(store, namespace, machine.id).get(transcript.id) ?? null + : existingSession + const created = !stored + const priorMetadata = stored ? storedMetadata(stored) : {} + const priorImportState = asRecord(priorMetadata.dshImportState) + const observedEventSeq = typeof priorMetadata.dshHistoryLastEventSeq === 'number' + && priorImportState !== null + ? priorMetadata.dshHistoryLastEventSeq + : null + const importingState: NonNullable = { + state: 'importing', + machineId: machine.id, + dshSessionId: transcript.id, + sourceUrl, + startedAt, + updatedAt: startedAt, + lastEventSeq: transcript.lastEventSeq + } + + if (!stored) { + stored = store.sessions.getOrCreateSession( + `dsh-import:${machine.id}:${transcript.id}`, + buildDshMetadata(transcript, machine, sourceUrl, {}, importingState), + {}, + namespace, + transcript.model ?? undefined, + undefined, + transcript.reasoningEffort ?? undefined + ) + } else { + updateMetadataWithRetry(store, stored.id, namespace, (metadata) => + buildDshMetadata(transcript, machine, sourceUrl, metadata, importingState)) + } + + const delta = classifyImportDelta(store.messages.getAllMessages(stored.id), transcript, observedEventSeq) + if (delta.error) { + markImportState({ store, engine, sessionId: stored.id, namespace, transcript, machineId: machine.id, sourceUrl, state: 'diverged', error: delta.error }) + return { + dshSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: 'transcript_diverged', message: delta.error } + } + } + if (stored.active && delta.messages.length > 0) { + const message = 'The HAPI DeepSeek Harness session is active; stop it before importing history changes' + markImportState({ store, engine, sessionId: stored.id, namespace, transcript, machineId: machine.id, sourceUrl, state: 'failed', error: message }) + return { + dshSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: 'session_active', message } + } + } + + const appended: StoredMessage[] = [] + try { + for (const source of delta.messages) { + const result = store.messages.addImportedMessage(stored.id, source.content, source.localId, source.createdAt) + if (result.inserted) appended.push(result.message) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to persist DeepSeek Harness history' + const state = error instanceof ImportedMessageConflictError ? 'diverged' : 'failed' + markImportState({ store, engine, sessionId: stored.id, namespace, transcript, machineId: machine.id, sourceUrl, state, error: message }) + return { + dshSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: state === 'diverged' ? 'transcript_diverged' : 'import_failed', message } + } + } + + try { + updateMetadataWithRetry(store, stored.id, namespace, (latest) => buildDshMetadata( + transcript, + machine, + sourceUrl, + latest, + { + state: 'complete', + machineId: machine.id, + dshSessionId: transcript.id, + sourceUrl, + startedAt, + updatedAt: Date.now(), + lastEventSeq: transcript.lastEventSeq + } + )) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to finalize DeepSeek Harness history import' + try { + markImportState({ store, engine, sessionId: stored.id, namespace, transcript, machineId: machine.id, sourceUrl, state: 'failed', error: message }) + } catch {} + return { dshSessionId: transcript.id, hapiSessionId: stored.id, error: { code: 'import_failed', message } } + } + + if (transcript.model !== undefined) { + store.sessions.setSessionModel(stored.id, transcript.model ?? null, namespace, { touchUpdatedAt: false }) + } + if (transcript.reasoningEffort !== undefined) { + store.sessions.setSessionModelReasoningEffort( + stored.id, + transcript.reasoningEffort ?? null, + namespace, + { touchUpdatedAt: false } + ) + } + engine.recordSessionActivity(stored.id, appended.at(-1)?.createdAt ?? transcript.modifiedAt) + emitImportedMessages(engine, stored.id, appended) + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: stored.id }) + return { + dshSessionId: transcript.id, + hapiSessionId: stored.id, + action: created ? 'created' : appended.length > 0 ? 'updated' : 'unchanged', + appended: appended.length + } +} + +async function importWithLock(key: string, work: () => DshImportResult): Promise { + const prior = importLocks.get(key) + if (prior) return prior + const current = Promise.resolve().then(work) + importLocks.set(key, current) + try { + return await current + } finally { + if (importLocks.get(key) === current) importLocks.delete(key) + } +} + +export function createDshSessionRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.get('/dsh/sessions', async (c) => { + const namespace = c.get('namespace') + const engine = options.getSyncEngine() + const machine = resolveDshMachine(engine, namespace, c.req.query('machineId')?.trim() || null) + if (!engine || !machine) { + return c.json({ success: false, error: 'No online machine available for DeepSeek Harness import', sessions: [] }, 503) + } + const result = await engine.listDshSessionsForMachine(machine.id, c.req.query('cwd')?.trim() || null) + if (!result.success) { + return c.json({ success: false, error: result.error, sessions: [], machineId: machine.id }, 503) + } + const imported = importedDshSessionsById(options.store, namespace, machine.id) + const sessions: DshSessionListItem[] = result.sessions.map((summary) => { + const hapiSession = imported.get(summary.id) + const importState = asRecord(hapiSession ? storedMetadata(hapiSession).dshImportState : null)?.state + return { + ...summary, + ...(hapiSession ? { hapiSessionId: hapiSession.id } : {}), + ...(importState === 'importing' || importState === 'complete' || importState === 'failed' || importState === 'diverged' + ? { importState } + : {}) + } + }) + return c.json({ success: true, sessions, machineId: machine.id }) + }) + + app.post('/dsh/import-sessions', async (c) => { + const body = asRecord(await c.req.json().catch(() => null)) + const sessionIds = Array.isArray(body?.sessionIds) + ? body.sessionIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0).map((id) => id.trim()) + : [] + if (sessionIds.length === 0) { + return c.json({ success: false, error: 'No DeepSeek Harness sessions selected', results: [] }, 400) + } + const uniqueSessionIds = [...new Set(sessionIds)] + const namespace = c.get('namespace') + const engine = options.getSyncEngine() + const machine = resolveDshMachine( + engine, + namespace, + typeof body?.machineId === 'string' ? body.machineId.trim() : null + ) + if (!engine || !machine) { + return c.json({ success: false, error: 'No online machine available for DeepSeek Harness import', results: [] }, 503) + } + const remote = await engine.listDshSessionsForMachine( + machine.id, + typeof body?.cwd === 'string' ? body.cwd.trim() : null, + uniqueSessionIds + ) + if (!remote.success) { + return c.json({ success: false, error: remote.error, results: [], machineId: machine.id }, 503) + } + const byId = new Map(remote.sessions + .filter((session): session is DshLocalSessionWithMessages => 'messages' in session) + .map((session) => [session.id, session])) + const imported = importedDshSessionsById(options.store, namespace, machine.id) + const results: DshImportResult[] = [] + for (const sessionId of uniqueSessionIds) { + const transcript = byId.get(sessionId) + if (!transcript) { + results.push({ + dshSessionId: sessionId, + error: { code: 'not_found', message: 'DeepSeek Harness session history not found' } + }) + continue + } + results.push(await importWithLock(`${namespace}:${machine.id}:${sessionId}`, () => importDshSession({ + store: options.store, + engine, + namespace, + machine, + sourceUrl: remote.sourceUrl, + transcript, + existingSession: imported.get(sessionId) ?? null + }))) + } + return c.json({ success: results.every((result) => !result.error), results, machineId: machine.id }) + }) + + return app +} diff --git a/hub/src/web/routes/events.ts b/hub/src/web/routes/events.ts index 0ccf28a6..fb82c69a 100644 --- a/hub/src/web/routes/events.ts +++ b/hub/src/web/routes/events.ts @@ -36,7 +36,11 @@ const visibilitySchema = z.object({ export function createEventsRoutes( getSseManager: () => SSEManager | null, getSyncEngine: () => SyncEngine | null, - getVisibilityTracker: () => VisibilityTracker | null + getVisibilityTracker: () => VisibilityTracker | null, + options?: { + isGuestTokenActive?: (shareToken: string) => boolean + guestCheckIntervalMs?: number + } ): Hono { const app = new Hono() @@ -59,9 +63,19 @@ export function createEventsRoutes( const resumeFrom = parseOptionalId(c.req.header('Last-Event-ID')) ?? parseOptionalId(query.lastEventId) const namespace = c.get('namespace') - let resolvedSessionId = sessionId + const guestSessionId = c.get('sessionId') + const guestShareToken = c.get('shareToken') + const isGuestShareActive = () => !guestShareToken + || options?.isGuestTokenActive?.(guestShareToken) === true + if (!isGuestShareActive()) { + return c.json({ error: 'Share revoked or expired' }, 401) + } + if (guestSessionId && (all || machineId || (sessionId && sessionId !== guestSessionId))) { + return c.json({ error: 'Guest access is limited to the shared session' }, 403) + } + let resolvedSessionId = guestSessionId ?? sessionId - if (sessionId || machineId) { + if (sessionId || machineId || guestSessionId) { const engine = getSyncEngine() if (!engine) { return c.json({ error: 'Not connected' }, 503) @@ -131,7 +145,25 @@ export function createEventsRoutes( await manager.drainPending(subscriptionId) await new Promise((resolve) => { - const done = () => resolve() + let settled = false + let guestCheckInterval: ReturnType | null = null + const done = () => { + if (settled) { + return + } + settled = true + if (guestCheckInterval) { + clearInterval(guestCheckInterval) + } + resolve() + } + if (guestShareToken && options?.isGuestTokenActive) { + guestCheckInterval = setInterval(() => { + if (!isGuestShareActive()) { + done() + } + }, options.guestCheckIntervalMs ?? 5_000) + } c.req.raw.signal.addEventListener('abort', done, { once: true }) stream.onAbort(done) }) diff --git a/hub/src/web/routes/guards.ts b/hub/src/web/routes/guards.ts index 0c2b2351..713779bc 100644 --- a/hub/src/web/routes/guards.ts +++ b/hub/src/web/routes/guards.ts @@ -20,6 +20,10 @@ export function requireSession( options?: { requireActive?: boolean } ): { sessionId: string; session: Session } | Response { const namespace = c.get('namespace') + const guestSessionId = c.get('sessionId') + if (guestSessionId && guestSessionId !== sessionId) { + return c.json({ error: 'Session access denied' }, 403) + } const access = engine.resolveSessionAccess(sessionId, namespace) if (!access.ok) { const status = access.reason === 'access-denied' ? 403 : 404 @@ -55,6 +59,9 @@ export function requireMachine( engine: SyncEngine, machineId: string ): Machine | Response { + if (c.get('role') === 'session-guest') { + return c.json({ error: 'Machine access denied' }, 403) + } const namespace = c.get('namespace') const machine = engine.getMachine(machineId) if (!machine) { diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index d0109287..ac21902a 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -185,18 +185,23 @@ describe('machines routes', () => { }) }) - it('returns Pi models for an online machine', async () => { + it('returns DeepSeek Harness models for an online machine', async () => { const machine = createMachine() + const result = { + success: true as const, + current: { provider: 'deepseek-official', modelId: 'deepseek-v4-pro' }, + availableModels: [{ + provider: 'deepseek-official', + providerName: 'DeepSeek', + modelId: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + reasoningEfforts: [] + }] + } const engine = { getMachine: () => machine, getMachineByNamespace: () => machine, - listPiModelsForMachine: async () => ({ - success: true, - availableModels: [ - { provider: 'openai-codex', modelId: 'gpt-5.6-sol', reasoning: true } - ], - currentModelId: null - }) + listDshModelsForMachine: async () => result } as Partial const app = new Hono() @@ -206,16 +211,9 @@ describe('machines routes', () => { }) app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) - const response = await app.request('/api/machines/machine-1/pi-models') - + const response = await app.request('/api/machines/machine-1/dsh-models') expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - success: true, - availableModels: [ - { provider: 'openai-codex', modelId: 'gpt-5.6-sol', reasoning: true } - ], - currentModelId: null - }) + expect(await response.json()).toEqual(result) }) it('returns a stable code when the Codex machine RPC target is absent', async () => { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 5a98356a..bd6d1c99 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -283,6 +283,33 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/dsh-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) return machine + + try { + return c.json(await engine.listDshModelsForMachine(machineId)) + } catch (error) { + if (error instanceof RpcTargetMissingError) { + return c.json({ + success: false, + error: error.message, + code: RPC_TARGET_MISSING_ERROR_CODE + }, 503) + } + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list DeepSeek Harness models' + }, 500) + } + }) + app.get('/machines/:id/opencode-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/messages.ts b/hub/src/web/routes/messages.ts index 92e46e22..c26b2b8f 100644 --- a/hub/src/web/routes/messages.ts +++ b/hub/src/web/routes/messages.ts @@ -104,7 +104,6 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho return sessionResult } const sessionId = sessionResult.sessionId - const body = await c.req.json().catch(() => null) const parsed = QueuedStateRequestSchema.safeParse(body) if (!parsed.success) { @@ -118,6 +117,23 @@ export function createMessagesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json(engine.getQueuedState(sessionId, localIds)) }) + app.post('/sessions/:id/messages/:messageId/steer', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + const sessionId = sessionResult.sessionId + const messageId = c.req.param('messageId') + + const result = await engine.steerQueuedMessage(sessionId, messageId) + return c.json(result) + }) + app.post('/sessions/:id/messages', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/hub/src/web/routes/sessionShares.test.ts b/hub/src/web/routes/sessionShares.test.ts new file mode 100644 index 00000000..e180ff91 --- /dev/null +++ b/hub/src/web/routes/sessionShares.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from "bun:test"; +import { SignJWT, decodeJwt } from "jose"; +import { Hono } from "hono"; +import { Store } from "../../store"; +import type { SyncEngine } from "../../sync/syncEngine"; +import { createAuthMiddleware } from "../middleware/auth"; +import type { WebAppEnv } from "../middleware/auth"; +import { + createSessionShareRoutes, + resetSessionShareExchangeRateLimitForTests, +} from "./sessionShares"; +import { createSessionsRoutes } from "./sessions"; + +const JWT_SECRET = new TextEncoder().encode("session-share-test-secret"); + +function createSession( + store: Store, + sessionId = "session-1", + namespace = "default", +) { + return store.sessions.getOrCreateSession( + `share-${sessionId}`, + { path: "/repo", host: "host", flavor: "codex", name: "Shared session" }, + null, + namespace, + undefined, + undefined, + undefined, + sessionId, + ); +} + +async function ownerToken(namespace = "default"): Promise { + return await new SignJWT({ uid: 1, ns: namespace }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(JWT_SECRET); +} + +function createApp( + store: Store, + engine: SyncEngine, + isGuestTokenActive = (token: string) => + store.sessionShares.getActiveByToken(token) !== null, +) { + const app = new Hono(); + app.use("/api/*", createAuthMiddleware(JWT_SECRET, { isGuestTokenActive })); + app.route( + "/api", + createSessionShareRoutes({ + store, + jwtSecret: JWT_SECRET, + getSyncEngine: () => engine, + }), + ); + app.route( + "/api", + createSessionsRoutes(() => engine), + ); + app.get("/api/machines", (c) => c.json({ machines: [] })); + return app; +} + +describe("session share routes", () => { + it("rate-limits repeated verification failures per share token", async () => { + resetSessionShareExchangeRateLimitForTests(); + const store = new Store(":memory:"); + const session = createSession(store); + const engine = { + resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), + getSessionsByNamespace: () => [session], + getFutureScheduledMessageCounts: () => new Map(), + getNextScheduledAtBySessionIds: () => new Map(), + } as unknown as SyncEngine; + const app = createApp(store, engine); + const owner = await ownerToken(); + const created = await app.request("/api/session-shares", { + method: "POST", + headers: { + authorization: `Bearer ${owner}`, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId: session.id }), + }); + const { share, accessCode } = (await created.json()) as { + share: { shareToken: string }; + accessCode: string; + }; + let lastStatus = 0; + for (let index = 0; index < 21; index += 1) { + const response = await app.request( + `/api/public/session-shares/${share.shareToken}/exchange`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accessCode: accessCode === "000000" ? "999999" : "000000" }), + }, + ); + lastStatus = response.status; + } + expect(lastStatus).toBe(429); + store.close(); + }); + + it("creates, exchanges, scopes, rotates, and revokes a collaborative share", async () => { + const store = new Store(":memory:"); + const session = createSession(store); + const otherSession = createSession(store, "session-2"); + const engine = { + resolveSessionAccess: (sessionId: string, namespace: string) => { + const selected = + sessionId === session.id + ? session + : sessionId === otherSession.id + ? otherSession + : null; + if (!selected) return { ok: false, reason: "not-found" as const }; + if (selected.namespace !== namespace) + return { ok: false, reason: "access-denied" as const }; + return { ok: true as const, sessionId: selected.id, session: selected }; + }, + getSessionsByNamespace: (namespace: string) => + [session, otherSession].filter((item) => item.namespace === namespace), + getFutureScheduledMessageCounts: () => new Map(), + getNextScheduledAtBySessionIds: () => new Map(), + } as unknown as SyncEngine; + const app = createApp(store, engine); + const owner = await ownerToken(); + + const created = await app.request("/api/session-shares", { + method: "POST", + headers: { + authorization: `Bearer ${owner}`, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId: session.id }), + }); + expect(created.status).toBe(200); + const createdBody = (await created.json()) as { + share: { id: string; shareToken: string }; + accessCode: string; + }; + expect(createdBody.accessCode).toMatch(/^\d{6}$/); + + const activeShares = await app.request("/api/session-shares", { + headers: { authorization: `Bearer ${owner}` }, + }); + expect(activeShares.status).toBe(200); + expect(await activeShares.json()).toMatchObject({ + shares: [ + { + share: { + id: createdBody.share.id, + sessionId: session.id, + }, + session: { + id: session.id, + }, + }, + ], + }); + + const exchanged = await app.request( + `/api/public/session-shares/${createdBody.share.shareToken}/exchange`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accessCode: createdBody.accessCode }), + }, + ); + expect(exchanged.status).toBe(200); + const guestToken = ((await exchanged.json()) as { token: string }).token; + expect(decodeJwt(guestToken)).not.toHaveProperty("exp"); + + const guestSessions = await app.request("/api/sessions", { + headers: { authorization: `Bearer ${guestToken}` }, + }); + expect(guestSessions.status).toBe(200); + expect( + ( + (await guestSessions.json()) as { sessions: Array<{ id: string }> } + ).sessions.map((item) => item.id), + ).toEqual([session.id]); + + const guestGlobal = await app.request("/api/machines", { + headers: { authorization: `Bearer ${guestToken}` }, + }); + expect(guestGlobal.status).toBe(403); + + const guestShares = await app.request("/api/session-shares", { + headers: { authorization: `Bearer ${guestToken}` }, + }); + expect(guestShares.status).toBe(403); + + const forbiddenGuestConfigurationRequests = [ + { path: "permission-mode", body: { mode: "auto" } }, + { path: "collaboration-mode", body: { mode: "plan" } }, + { path: "copilot-agent-mode", body: { mode: "autonomous" } }, + { path: "model", body: { model: "gpt-next" } }, + { + path: "model-reasoning-effort", + body: { modelReasoningEffort: "high" }, + }, + { path: "effort", body: { effort: "high" } }, + { path: "service-tier", body: { serviceTier: "fast" } }, + ]; + for (const request of forbiddenGuestConfigurationRequests) { + const response = await app.request( + `/api/sessions/${session.id}/${request.path}`, + { + method: "POST", + headers: { + authorization: `Bearer ${guestToken}`, + "content-type": "application/json", + }, + body: JSON.stringify(request.body), + }, + ); + expect(response.status).toBe(403); + } + + const guestModels = await app.request( + `/api/sessions/${session.id}/codex-models`, + { headers: { authorization: `Bearer ${guestToken}` } }, + ); + expect(guestModels.status).toBe(403); + + const second = await app.request("/api/session-shares", { + method: "POST", + headers: { + authorization: `Bearer ${owner}`, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId: session.id }), + }); + const secondBody = (await second.json()) as { + share: { id: string; shareToken: string }; + accessCode: string; + }; + expect(secondBody.share.shareToken).not.toBe(createdBody.share.shareToken); + + const oldGuest = await app.request("/api/sessions", { + headers: { authorization: `Bearer ${guestToken}` }, + }); + expect(oldGuest.status).toBe(401); + + const revoke = await app.request( + `/api/session-shares/${secondBody.share.id}`, + { + method: "DELETE", + headers: { authorization: `Bearer ${owner}` }, + }, + ); + expect(revoke.status).toBe(200); + const activeSharesAfterRevoke = await app.request("/api/session-shares", { + headers: { authorization: `Bearer ${owner}` }, + }); + expect(activeSharesAfterRevoke.status).toBe(200); + expect((await activeSharesAfterRevoke.json()) as { shares: unknown[] }).toEqual({ shares: [] }); + store.close(); + }); + + it("rejects an invalid access code without revealing the session", async () => { + const store = new Store(":memory:"); + const session = createSession(store); + const engine = { + resolveSessionAccess: () => ({ + ok: true, + sessionId: session.id, + session, + }), + getSessionsByNamespace: () => [session], + getFutureScheduledMessageCounts: () => new Map(), + getNextScheduledAtBySessionIds: () => new Map(), + } as unknown as SyncEngine; + const app = createApp(store, engine); + const owner = await ownerToken(); + const created = await app.request("/api/session-shares", { + method: "POST", + headers: { + authorization: `Bearer ${owner}`, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId: session.id }), + }); + const { share, accessCode } = (await created.json()) as { + share: { shareToken: string }; + accessCode: string; + }; + + const response = await app.request( + `/api/public/session-shares/${share.shareToken}/exchange`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accessCode: accessCode === "000000" ? "999999" : "000000" }), + }, + ); + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ + code: "invalid_access_code", + }); + store.close(); + }); +}); diff --git a/hub/src/web/routes/sessionShares.ts b/hub/src/web/routes/sessionShares.ts new file mode 100644 index 00000000..105793f3 --- /dev/null +++ b/hub/src/web/routes/sessionShares.ts @@ -0,0 +1,197 @@ +import { Hono } from "hono"; +import { SignJWT } from "jose"; +import { z } from "zod"; +import { toSessionSummary } from '@hapi/protocol'; +import type { Store } from "../../store"; +import type { SyncEngine } from "../../sync/syncEngine"; +import type { WebAppEnv } from "../middleware/auth"; +import { requireSession, requireSyncEngine } from "./guards"; + +const createSchema = z.object({ sessionId: z.string().min(1) }); +const exchangeSchema = z.object({ + accessCode: z + .string() + .trim() + .regex(/^\d{6}$/), +}); + +const EXCHANGE_RATE_WINDOW_MS = 60_000; +const EXCHANGE_RATE_LIMIT = 20; +const MAX_EXCHANGE_RATE_BUCKETS = 2_000; +const exchangeRateBuckets = new Map(); + +function allowExchange(token: string, now = Date.now()): boolean { + const windowStart = now - EXCHANGE_RATE_WINDOW_MS; + for (const [bucketToken, times] of exchangeRateBuckets) { + const recent = times.filter((time) => time >= windowStart); + if (recent.length === 0) exchangeRateBuckets.delete(bucketToken); + else if (recent.length !== times.length) exchangeRateBuckets.set(bucketToken, recent); + } + while (exchangeRateBuckets.size >= MAX_EXCHANGE_RATE_BUCKETS && !exchangeRateBuckets.has(token)) { + const oldestToken = exchangeRateBuckets.keys().next().value as string | undefined; + if (!oldestToken) break; + exchangeRateBuckets.delete(oldestToken); + } + const recent = (exchangeRateBuckets.get(token) ?? []).filter((time) => time >= windowStart); + if (recent.length >= EXCHANGE_RATE_LIMIT) { + exchangeRateBuckets.set(token, recent); + return false; + } + recent.push(now); + exchangeRateBuckets.set(token, recent); + return true; +} + +export function resetSessionShareExchangeRateLimitForTests(): void { + exchangeRateBuckets.clear(); +} + +function sharePayload(share: { + id: string; + sessionId: string; + shareToken: string; + createdAt: number; + updatedAt: number; +}) { + return { + id: share.id, + sessionId: share.sessionId, + shareToken: share.shareToken, + createdAt: share.createdAt, + updatedAt: share.updatedAt, + }; +} + +export function createSessionShareRoutes(options: { + store: Store; + jwtSecret: Uint8Array; + getSyncEngine: () => SyncEngine | null; +}): Hono { + const app = new Hono(); + + app.post("/session-shares", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = createSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "Invalid body" }, 400); + + const engine = requireSyncEngine(c, options.getSyncEngine); + if (engine instanceof Response) return engine; + const sessionResult = requireSession(c, engine, parsed.data.sessionId); + if (sessionResult instanceof Response) return sessionResult; + if (c.get("role") === "session-guest") + return c.json({ error: "Guest cannot manage shares" }, 403); + + const created = options.store.sessionShares.createShare( + sessionResult.sessionId, + c.get("namespace"), + ); + c.header("Cache-Control", "no-store"); + return c.json({ + share: sharePayload(created.share), + accessCode: created.accessCode, + }); + }); + + app.get("/session-shares", (c) => { + if (c.get("role") === "session-guest") + return c.json({ error: "Guest cannot manage shares" }, 403); + const engine = requireSyncEngine(c, options.getSyncEngine); + if (engine instanceof Response) return engine; + + const sessionsById = new Map( + engine.getSessionsByNamespace(c.get("namespace")).map((session) => [session.id, session]), + ); + const shares = options.store.sessionShares + .getActiveByNamespace(c.get("namespace")) + .map((share) => { + const session = sessionsById.get(share.sessionId); + return { + share: sharePayload(share), + session: session ? toSessionSummary(session) : null, + }; + }); + c.header("Cache-Control", "no-store"); + return c.json({ shares }); + }); + + app.get("/session-shares/session/:sessionId", (c) => { + if (c.get("role") === "session-guest") + return c.json({ error: "Guest cannot manage shares" }, 403); + const engine = requireSyncEngine(c, options.getSyncEngine); + if (engine instanceof Response) return engine; + const sessionResult = requireSession(c, engine, c.req.param("sessionId")); + if (sessionResult instanceof Response) return sessionResult; + const share = options.store.sessionShares.getActiveBySession( + sessionResult.sessionId, + c.get("namespace"), + ); + return c.json({ share: share ? sharePayload(share) : null }); + }); + + app.delete("/session-shares/:id", (c) => { + if (c.get("role") === "session-guest") + return c.json({ error: "Guest cannot manage shares" }, 403); + const revoked = options.store.sessionShares.revokeById( + c.req.param("id"), + c.get("namespace"), + ); + return revoked + ? c.json({ ok: true }) + : c.json({ error: "Share not found" }, 404); + }); + + app.post("/public/session-shares/:token/exchange", async (c) => { + const shareTokenParam = c.req.param("token"); + if (!allowExchange(shareTokenParam)) { + c.header("Retry-After", "60"); + return c.json( + { error: "Too many verification attempts; try again shortly", code: "rate_limited" }, + 429, + ); + } + const parsed = exchangeSchema.safeParse( + await c.req.json().catch(() => null), + ); + if (!parsed.success) + return c.json( + { error: "Invalid access code", code: "invalid_access_code" }, + 400, + ); + + const share = options.store.sessionShares.verifyCode( + shareTokenParam, + parsed.data.accessCode, + ); + if (!share) { + return c.json( + { + error: "Invalid access code or revoked share", + code: "invalid_access_code", + }, + 401, + ); + } + + exchangeRateBuckets.delete(shareTokenParam); + + const token = await new SignJWT({ + uid: 0, + ns: share.namespace, + sid: share.sessionId, + sht: share.shareToken, + role: "session-guest", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .sign(options.jwtSecret); + + c.header("Cache-Control", "no-store"); + return c.json({ + token, + sessionId: share.sessionId, + share: sharePayload(share), + }); + }); + + return app; +} diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 3346eb64..3377601d 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -65,6 +65,7 @@ function createApp(session: Session, opts?: { archiveSession?: (sessionId: string) => Promise getCursorChatStoreStatus?: SyncEngine['getCursorChatStoreStatus'] listCodexModelsForSession?: SyncEngine['listCodexModelsForSession'] + listDshModelsForSession?: SyncEngine['listDshModelsForSession'] forkConversation?: SyncEngine['forkConversation'] clearConversation?: SyncEngine['clearConversation'] implementCodexPlan?: SyncEngine['implementCodexPlan'] @@ -136,6 +137,10 @@ function createApp(session: Session, opts?: { success: true, models: [] })), + listDshModelsForSession: opts?.listDshModelsForSession ?? (async () => ({ + success: true, + availableModels: [] + })), listOpencodeModelsForSession, listOpencodeReasoningEffortOptionsForSession, listGrokModelsForSession, @@ -360,6 +365,34 @@ describe('sessions routes', () => { }) }) + it('uses session-scoped DeepSeek Harness model discovery', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'dsh', + machineId: 'machine-1' + } + }) + const captured: string[] = [] + const result = { + success: true as const, + current: { provider: 'deepseek-official', modelId: 'deepseek-v4-pro' }, + availableModels: [] + } + const { app } = createApp(session, { + listDshModelsForSession: async (sessionId) => { + captured.push(sessionId) + return result + } + }) + + const response = await app.request('/api/sessions/session-1/dsh-models') + expect(response.status).toBe(200) + expect(captured).toEqual(['session-1']) + expect(await response.json()).toEqual(result) + }) + it('returns the machine-scoped Cursor chat store status', async () => { const session = createSession({ active: false, @@ -587,7 +620,7 @@ describe('sessions routes', () => { expect(response.status).toBe(400) expect(await response.json()).toEqual({ - error: 'Model reasoning effort is only supported for Codex and OpenCode sessions' + error: 'Model reasoning effort is only supported for Codex, DeepSeek Harness, and OpenCode sessions' }) expect(applySessionConfigCalls).toEqual([]) }) @@ -656,6 +689,29 @@ describe('sessions routes', () => { ]) }) + it('applies model reasoning effort changes for remote DeepSeek Harness sessions', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'dsh' + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model-reasoning-effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ modelReasoningEffort: 'max' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { modelReasoningEffort: 'max' }] + ]) + }) + it('applies fast service tier changes for remote Codex sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index e8fea7ce..73591d0a 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -80,6 +80,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const getPendingCount = (s: Session) => s.agentState?.requests ? Object.keys(s.agentState.requests).length : 0 const namespace = c.get('namespace') + const guestSessionId = c.get('sessionId') const limitRaw = c.req.query('limit') const parsedLimit = limitRaw === undefined ? null : Number(limitRaw) const limit = parsedLimit !== null && Number.isFinite(parsedLimit) @@ -87,7 +88,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho : null const order = c.req.query('order') - let sessionRecords = engine.getSessionsByNamespace(namespace) + let sessionRecords = (guestSessionId + ? engine.getSessionsByNamespace(namespace).filter((session) => session.id === guestSessionId) + : engine.getSessionsByNamespace(namespace)) .sort((a, b) => { // Peer discovery wants newest activity first before limit truncation. if (order === 'updatedAt') { @@ -364,6 +367,21 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ ok: true }) }) + app.post('/sessions/:id/retry-codex-turn', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + // A capacity failure can leave the CLI disconnected before the user + // clicks Retry. SyncEngine.retryCodexTurn reopens the session and + // requeues the last prompt when needed, so this endpoint must not + // reject inactive sessions at the guard layer. + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) return sessionResult + const result = await engine.retryCodexTurn(sessionResult.sessionId) + // Keep retry outcomes in-band so the web client can show the reason + // instead of swallowing a 409 as a no-op. + return c.json(result) + }) + app.post('/sessions/:id/fork', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { @@ -731,8 +749,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'codex' && flavor !== 'opencode') { - return c.json({ error: 'Model reasoning effort is only supported for Codex and OpenCode sessions' }, 400) + if (flavor !== 'codex' && flavor !== 'dsh' && flavor !== 'opencode') { + return c.json({ error: 'Model reasoning effort is only supported for Codex, DeepSeek Harness, and OpenCode sessions' }, 400) } if (sessionResult.session.agentState?.controlledByUser === true && !sessionResult.session.metadata?.capabilities?.concurrentClients) { return c.json({ error: 'Model reasoning effort can only be changed for remote sessions' }, 409) @@ -1392,6 +1410,29 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/dsh-models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + if (sessionResult.session.metadata?.flavor !== 'dsh') { + return c.json({ + success: false, + error: 'DeepSeek Harness models are only available for DeepSeek Harness sessions' + }, 400) + } + + try { + return c.json(await engine.listDshModelsForSession(sessionResult.sessionId)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list DeepSeek Harness models' + }, 500) + } + }) + app.get('/sessions/:id/opencode-models', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/hub/src/web/routes/studios.test.ts b/hub/src/web/routes/studios.test.ts new file mode 100644 index 00000000..65eae3ed --- /dev/null +++ b/hub/src/web/routes/studios.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' +import { createPublicStudioRoutes, createStudioRoutes, resetStudioPostRateLimitForTests } from './studios' + +function createSession(store: Store, namespace = 'default', sessionId = 'session-1') { + store.sessions.getOrCreateSession( + `tag-${sessionId}`, + { path: '/repo', host: 'private-host', flavor: 'codex', name: 'Private session' }, + null, + namespace, + undefined, + undefined, + undefined, + sessionId + ) +} + +function createApp(store: Store, engine: unknown) { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + c.set('userId', 1) + await next() + }) + app.route('/api', createStudioRoutes({ store, getSyncEngine: () => engine as never })) + app.route('/api', createPublicStudioRoutes({ store, getSyncEngine: () => engine as never })) + return app +} + +describe('studio routes', () => { + it('limits a room even when guests rotate their client ids', async () => { + resetStudioPostRateLimitForTests() + const store = new Store(':memory:') + createSession(store) + const room = store.studios.createOrActivateRoom('session-1', 'default', 'Room', 'contribute') + const engine = { + resolveSessionAccess: () => ({ ok: true, sessionId: 'session-1', session: { id: 'session-1', namespace: 'default', active: true, metadata: {} } }) + } + const app = createApp(store, engine) + const statuses: number[] = [] + for (let index = 0; index < 61; index += 1) { + const response = await app.request(`/api/public/studios/${room.shareToken}/posts`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ guestId: `guest-${String(index).padStart(8, '0')}`, authorName: 'Guest', kind: 'discussion', text: `post-${index}` }) + }) + statuses.push(response.status) + } + expect(statuses.at(-1)).toBe(429) + }) + + it('creates an isolated room and exposes a redacted public transcript', async () => { + const store = new Store(':memory:') + createSession(store) + const engine = { + resolveSessionAccess: () => ({ + ok: true, + sessionId: 'session-1', + session: { + id: 'session-1', + namespace: 'default', + active: true, + model: 'gpt-test', + metadata: { flavor: 'codex', path: '/private/repo', host: 'private-host' } + } + }), + sendMessage: async () => undefined + } + store.messages.addMessage('session-1', { role: 'user', content: { type: 'text', text: 'Review this' } }, 'visible-user') + store.messages.markMessagesInvoked('session-1', ['visible-user'], Date.now()) + store.messages.addMessage('session-1', { role: 'user', content: { type: 'text', text: 'Queued secret' } }, 'queued-user') + store.messages.addMessage('session-1', { role: 'agent', content: { type: 'codex', data: { type: 'message', message: 'Looks good.' } } }) + store.messages.addMessage('session-1', { role: 'agent', content: { type: 'output', data: { type: 'assistant', isMeta: true, message: { content: [{ type: 'text', text: 'Hidden meta' }] } } } }) + store.messages.addMessage('session-1', { role: 'agent', content: { type: 'output', data: { type: 'assistant', isCompactSummary: true, message: { content: [{ type: 'text', text: 'Hidden compact summary' }] } } } }) + const app = createApp(store, engine) + + const created = await app.request('/api/studios', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: 'session-1' }) + }) + expect(created.status).toBe(200) + const room = (await created.json() as { room: { id: string; shareToken: string } }).room + + const publicResponse = await app.request(`/api/public/studios/${room.shareToken}`) + expect(publicResponse.status).toBe(200) + expect(publicResponse.headers.get('cache-control')).toContain('no-store') + const body = await publicResponse.json() as { messages: Array<{ text: string }>; room: Record } + expect(body.messages.map((message) => message.text)).toEqual(['Review this', 'Looks good.']) + expect(body.room).not.toHaveProperty('shareToken') + expect(body.room).not.toHaveProperty('sessionId') + }) + + it('pages past tool-only raw rows to retain older visible conversation', async () => { + const store = new Store(':memory:') + createSession(store) + const engine = { + resolveSessionAccess: () => ({ ok: true, sessionId: 'session-1', session: { id: 'session-1', namespace: 'default', active: true, metadata: {} } }) + } + store.messages.addMessage('session-1', { role: 'agent', content: { type: 'codex', data: { type: 'message', message: 'Older visible answer' } } }) + for (let index = 0; index < 210; index += 1) { + store.messages.addMessage('session-1', { role: 'agent', content: { type: 'codex', data: { type: 'tool-call', name: 'exec', input: `${index}` } } }) + } + const room = store.studios.createOrActivateRoom('session-1', 'default', 'Room', 'contribute') + const app = createApp(store, engine) + + const response = await app.request(`/api/public/studios/${room.shareToken}`) + const body = await response.json() as { messages: Array<{ text: string }> } + expect(body.messages.map((message) => message.text)).toEqual(['Older visible answer']) + }) + + it('keeps guest suggestions out of the public room response', async () => { + const store = new Store(':memory:') + createSession(store) + const engine = { + resolveSessionAccess: () => ({ + ok: true, + sessionId: 'session-1', + session: { id: 'session-1', namespace: 'default', active: true, metadata: {} } + }) + } + const room = store.studios.createOrActivateRoom('session-1', 'default', 'Room', 'contribute') + store.studios.createPost({ roomId: room.id, guestId: 'guest-a-12345678', authorName: 'A', kind: 'discussion', text: 'Visible' }) + store.studios.createPost({ roomId: room.id, guestId: 'guest-b-12345678', authorName: 'B', kind: 'suggestion', text: 'Owner only' }) + const app = createApp(store, engine) + + const response = await app.request(`/api/public/studios/${room.shareToken}`) + const body = await response.json() as { posts: Array<{ kind: string; text: string }> } + expect(body.posts).toHaveLength(1) + expect(body.posts[0]).toMatchObject({ kind: 'discussion', text: 'Visible' }) + expect(body.posts[0]).not.toHaveProperty('guestId') + expect(body.posts[0]).not.toHaveProperty('status') + }) + + it('allows the owner to dismiss a public discussion', async () => { + const store = new Store(':memory:') + createSession(store) + const engine = { + resolveSessionAccess: () => ({ ok: true, sessionId: 'session-1', session: { id: 'session-1', namespace: 'default', active: true, metadata: {} } }) + } + const room = store.studios.createOrActivateRoom('session-1', 'default', 'Room', 'contribute') + const post = store.studios.createPost({ roomId: room.id, guestId: 'guest-a-12345678', authorName: 'A', kind: 'discussion', text: 'Remove me' }) + const app = createApp(store, engine) + + const decision = await app.request(`/api/studios/${room.id}/posts/${post.id}/decision`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'dismiss' }) + }) + expect(decision.status).toBe(200) + const publicResponse = await app.request(`/api/public/studios/${room.shareToken}`) + expect((await publicResponse.json() as { posts: unknown[] }).posts).toHaveLength(0) + }) + + it('allows the owner to clear all room posts', async () => { + const store = new Store(':memory:') + createSession(store) + const room = store.studios.createOrActivateRoom('session-1', 'default', 'Room', 'contribute') + store.studios.createPost({ roomId: room.id, guestId: 'guest-a-12345678', authorName: 'A', kind: 'discussion', text: 'Remove me' }) + const app = createApp(store, { resolveSessionAccess: () => ({ ok: true, sessionId: 'session-1', session: { id: 'session-1', namespace: 'default', active: true, metadata: {} } }) }) + + const response = await app.request(`/api/studios/${room.id}/posts`, { method: 'DELETE' }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true, deleted: 1 }) + expect(store.studios.listPosts(room.id)).toHaveLength(0) + }) + + it('keeps recent discussions and all suggestions independently beyond 200 mixed posts', async () => { + const store = new Store(':memory:') + createSession(store) + const engine = { + resolveSessionAccess: () => ({ ok: true, sessionId: 'session-1', session: { id: 'session-1', namespace: 'default', active: true, metadata: {} } }) + } + const room = store.studios.createOrActivateRoom('session-1', 'default', 'Room', 'contribute') + store.studios.createPost({ roomId: room.id, guestId: 'guest-a-12345678', authorName: 'A', kind: 'discussion', text: 'Discussion survives', createdAt: 0 }) + for (let index = 1; index <= 205; index += 1) { + store.studios.createPost({ roomId: room.id, guestId: 'guest-b-12345678', authorName: 'B', kind: 'suggestion', text: `suggestion-${index}`, createdAt: index }) + } + const app = createApp(store, engine) + + const publicResponse = await app.request(`/api/public/studios/${room.shareToken}`) + const publicBody = await publicResponse.json() as { posts: Array<{ text: string }> } + expect(publicBody.posts.map((post) => post.text)).toEqual(['Discussion survives']) + + const ownerResponse = await app.request(`/api/studios/${room.id}`) + const ownerBody = await ownerResponse.json() as { posts: Array<{ kind: string }> } + expect(ownerBody.posts.filter((post) => post.kind === 'suggestion')).toHaveLength(200) + }) + + it('queues a guest suggestion and submits it only through the owner decision endpoint', async () => { + const store = new Store(':memory:') + createSession(store) + const sent: string[] = [] + const engine = { + resolveSessionAccess: () => ({ + ok: true, + sessionId: 'session-1', + session: { id: 'session-1', namespace: 'default', active: true, metadata: {} } + }), + sendMessage: async (_sessionId: string, input: { text: string }) => { sent.push(input.text) } + } + const app = createApp(store, engine) + const created = await app.request('/api/studios', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: 'session-1' }) + }) + const room = (await created.json() as { room: { id: string; shareToken: string } }).room + + const postResponse = await app.request(`/api/public/studios/${room.shareToken}/posts`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ guestId: 'guest-12345678', authorName: 'Guest', kind: 'suggestion', text: 'Run tests' }) + }) + expect(postResponse.status).toBe(201) + const post = (await postResponse.json() as { post: { id: string; status: string } }).post + expect(post.status).toBe('open') + expect(sent).toEqual([]) + + const decision = await app.request(`/api/studios/${room.id}/posts/${post.id}/decision`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'submit', text: 'Run the focused tests first.' }) + }) + expect(decision.status).toBe(200) + expect(sent).toEqual(['[Studio suggestion from Guest]\nRun the focused tests first.']) + }) +}) diff --git a/hub/src/web/routes/studios.ts b/hub/src/web/routes/studios.ts new file mode 100644 index 00000000..2391ca2f --- /dev/null +++ b/hub/src/web/routes/studios.ts @@ -0,0 +1,361 @@ +import { + extractAssistantPlainText, + isObject, + unwrapRoleWrappedRecordEnvelope +} from '@hapi/protocol' +import { Hono } from 'hono' +import { z } from 'zod' +import type { Store, StoredMessage, StoredStudioPost, StoredStudioRoom } from '../../store' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSession, requireSyncEngine } from './guards' + +const createRoomSchema = z.object({ + sessionId: z.string().min(1), + title: z.string().trim().min(1).max(120).optional(), + accessMode: z.enum(['view', 'contribute']).default('contribute') +}) + +const updateRoomSchema = z.object({ + title: z.string().trim().min(1).max(120).optional(), + accessMode: z.enum(['view', 'contribute']).optional(), + rotateToken: z.boolean().optional() +}).refine((value) => Object.keys(value).length > 0) + +const postSchema = z.object({ + guestId: z.string().trim().min(8).max(100), + authorName: z.string().trim().min(1).max(40), + kind: z.enum(['discussion', 'suggestion']), + text: z.string().trim().min(1).max(2000) +}) + +const decidePostSchema = z.object({ + action: z.enum(['submit', 'dismiss']), + text: z.string().trim().min(1).max(4000).optional() +}) + +const suggestionPageQuerySchema = z.object({ + beforeAt: z.coerce.number().int().nonnegative().optional(), + beforeId: z.string().min(1).optional() +}).refine((value) => (value.beforeAt === undefined) === (value.beforeId === undefined)) + +type PublicStudioMessage = { + id: string + role: 'user' | 'assistant' + text: string + createdAt: number + seq: number +} + +type PublicStudioPost = Pick + +function projectPublicPost(post: StoredStudioPost): PublicStudioPost { + return { + id: post.id, + roomId: post.roomId, + authorName: post.authorName, + kind: post.kind, + text: post.text, + createdAt: post.createdAt + } +} + +function sessionTitle(session: Session): string { + const metadata = session.metadata + const name = typeof metadata?.name === 'string' ? metadata.name.trim() : '' + if (name) return name + const summary = typeof metadata?.summary?.text === 'string' ? metadata.summary.text.trim() : '' + if (summary) return summary + const path = typeof metadata?.path === 'string' ? metadata.path.trim() : '' + if (path) return path.split(/[\\/]/).filter(Boolean).at(-1) ?? 'Agent session' + return 'Agent session' +} + +function extractUserText(content: unknown): string | null { + if (typeof content === 'string') return content.trim() || null + if (!isObject(content)) return null + if (content.type === 'text' && typeof content.text === 'string') { + return content.text.trim() || null + } + if (content.type !== 'output' || !isObject(content.data) || content.data.type !== 'user') { + return null + } + const message = isObject(content.data.message) ? content.data.message : null + const blocks = Array.isArray(message?.content) ? message.content : [] + const text = blocks + .filter((block): block is Record => isObject(block)) + .filter((block) => block.type === 'text' && typeof block.text === 'string') + .map((block) => String(block.text)) + .join('\n') + .trim() + return text || null +} + +function projectMessage(message: StoredMessage): PublicStudioMessage | null { + const record = unwrapRoleWrappedRecordEnvelope(message.content) + if (!record) return null + if (record.role === 'user' && message.invokedAt === null) return null + if (isObject(record.content)) { + const data = isObject(record.content.data) ? record.content.data : null + if (data?.isMeta === true || data?.isCompactSummary === true) return null + } + if (record.role === 'user') { + const text = extractUserText(record.content) + return text ? { id: message.id, role: 'user', text, createdAt: message.createdAt, seq: message.seq } : null + } + if (record.role === 'agent' || record.role === 'assistant') { + const text = extractAssistantPlainText(record.content)?.trim() + return text ? { id: message.id, role: 'assistant', text, createdAt: message.createdAt, seq: message.seq } : null + } + return null +} + +function ownerRoomPayload(store: Store, roomId: string) { + const openSuggestionsNewestFirst = store.studios.listOpenSuggestionsPage(roomId, 200) + const posts = [ + ...store.studios.listPostsByKind(roomId, 'discussion', 200), + ...[...openSuggestionsNewestFirst].reverse(), + ...store.studios.listResolvedSuggestions(roomId, 50) + ].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)) + const oldest = openSuggestionsNewestFirst.at(-1) + return { + posts, + openSuggestionCount: store.studios.countOpenSuggestions(roomId), + nextSuggestionCursor: openSuggestionsNewestFirst.length === 200 && oldest + ? { beforeAt: oldest.createdAt, beforeId: oldest.id } + : null + } +} + +function publicRoom(room: StoredStudioRoom, session: Session) { + return { + id: room.id, + title: room.title, + accessMode: room.accessMode, + active: session.active, + agent: session.metadata?.flavor ?? 'agent', + model: session.model ?? null, + createdAt: room.createdAt, + updatedAt: room.updatedAt + } +} + +const POST_RATE_WINDOW_MS = 60_000 +const POST_RATE_LIMIT_PER_ROOM = 60 +const MAX_POST_RATE_BUCKETS = 1_000 +const MAX_STUDIO_POSTS_PER_ROOM = 2_000 +const MAX_RAW_MESSAGES_SCANNED = 2_000 +const postRateBuckets = new Map() + +function allowPost(key: string, now = Date.now()): boolean { + const windowStart = now - POST_RATE_WINDOW_MS + for (const [bucketKey, times] of postRateBuckets) { + const recent = times.filter((time) => time >= windowStart) + if (recent.length === 0) postRateBuckets.delete(bucketKey) + else if (recent.length !== times.length) postRateBuckets.set(bucketKey, recent) + } + while (postRateBuckets.size >= MAX_POST_RATE_BUCKETS && !postRateBuckets.has(key)) { + const oldestKey = postRateBuckets.keys().next().value as string | undefined + if (!oldestKey) break + postRateBuckets.delete(oldestKey) + } + const recent = (postRateBuckets.get(key) ?? []).filter((time) => time >= windowStart) + if (recent.length >= POST_RATE_LIMIT_PER_ROOM) { + postRateBuckets.set(key, recent) + return false + } + recent.push(now) + postRateBuckets.set(key, recent) + return true +} + +export function resetStudioPostRateLimitForTests(): void { + postRateBuckets.clear() +} + +export function createPublicStudioRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.get('/public/studios/:token', (c) => { + c.header('Cache-Control', 'no-store, private, max-age=0') + c.header('Pragma', 'no-cache') + const room = options.store.studios.getActiveRoomByToken(c.req.param('token')) + if (!room) return c.json({ error: 'Studio not found or link revoked' }, 404) + const engine = options.getSyncEngine() + const session = engine?.resolveSessionAccess(room.sessionId, room.namespace) + if (!engine || !session?.ok) return c.json({ error: 'Studio session unavailable' }, 503) + + const messages: PublicStudioMessage[] = [] + let before: { at: number; seq: number } | undefined + let scanned = 0 + while (messages.length < 200 && scanned < MAX_RAW_MESSAGES_SCANNED) { + const page = options.store.messages.getMessagesByPosition(room.sessionId, 200, before) + if (page.length === 0) break + scanned += page.length + const projectedPage = page + .map(projectMessage) + .filter((message): message is PublicStudioMessage => message !== null) + messages.unshift(...projectedPage) + const first = page[0] + before = first ? { at: first.invokedAt ?? first.createdAt, seq: first.seq } : undefined + if (page.length < 200) break + } + const publicMessages = messages.slice(-200) + const publicPosts = options.store.studios + .listPostsByKind(room.id, 'discussion', 200) + .filter((post) => post.status === 'open') + .map(projectPublicPost) + return c.json({ + room: publicRoom(room, session.session), + messages: publicMessages, + posts: publicPosts + }) + }) + + app.post('/public/studios/:token/posts', async (c) => { + const room = options.store.studios.getActiveRoomByToken(c.req.param('token')) + if (!room) return c.json({ error: 'Studio not found or link revoked' }, 404) + if (room.accessMode !== 'contribute') { + return c.json({ error: 'This studio is view only' }, 403) + } + const body = await c.req.json().catch(() => null) + const parsed = postSchema.safeParse(body) + if (!parsed.success) return c.json({ error: 'Invalid post' }, 400) + + if (!allowPost(`room:${room.id}`)) { + return c.json({ error: 'Too many posts; try again shortly' }, 429) + } + const post = options.store.studios.createPostWithinLimit( + { roomId: room.id, ...parsed.data }, + MAX_STUDIO_POSTS_PER_ROOM + ) + if (!post) return c.json({ error: 'This studio has reached its post limit' }, 429) + return c.json({ post }, 201) + }) + + return app +} + +export function createStudioRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.post('/studios', async (c) => { + const engine = requireSyncEngine(c, options.getSyncEngine) + if (engine instanceof Response) return engine + const body = await c.req.json().catch(() => null) + const parsed = createRoomSchema.safeParse(body) + if (!parsed.success) return c.json({ error: 'Invalid body' }, 400) + const sessionResult = requireSession(c, engine, parsed.data.sessionId) + if (sessionResult instanceof Response) return sessionResult + + const room = options.store.studios.createOrActivateRoom( + sessionResult.sessionId, + c.get('namespace'), + parsed.data.title ?? sessionTitle(sessionResult.session), + parsed.data.accessMode + ) + return c.json({ room }) + }) + + app.get('/studios/session/:sessionId', (c) => { + const engine = requireSyncEngine(c, options.getSyncEngine) + if (engine instanceof Response) return engine + const sessionResult = requireSession(c, engine, c.req.param('sessionId')) + if (sessionResult instanceof Response) return sessionResult + const room = options.store.studios.getRoomBySession(sessionResult.sessionId, c.get('namespace')) + return room ? c.json({ room, ...ownerRoomPayload(options.store, room.id) }) : c.json({ room: null, posts: [], openSuggestionCount: 0, nextSuggestionCursor: null }) + }) + + app.get('/studios/:id/suggestions', (c) => { + const room = options.store.studios.getRoomById(c.req.param('id'), c.get('namespace')) + if (!room) return c.json({ error: 'Studio not found' }, 404) + const parsed = suggestionPageQuerySchema.safeParse(c.req.query()) + if (!parsed.success) return c.json({ error: 'Invalid query' }, 400) + const before = parsed.data.beforeAt !== undefined && parsed.data.beforeId !== undefined + ? { createdAt: parsed.data.beforeAt, id: parsed.data.beforeId } + : undefined + const items = options.store.studios.listOpenSuggestionsPage(room.id, 200, before) + const oldest = items.at(-1) + return c.json({ + items, + nextCursor: items.length === 200 && oldest + ? { beforeAt: oldest.createdAt, beforeId: oldest.id } + : null + }) + }) + + app.get('/studios/:id', (c) => { + const room = options.store.studios.getRoomById(c.req.param('id'), c.get('namespace')) + if (!room) return c.json({ error: 'Studio not found' }, 404) + return c.json({ room, ...ownerRoomPayload(options.store, room.id) }) + }) + + app.patch('/studios/:id', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = updateRoomSchema.safeParse(body) + if (!parsed.success) return c.json({ error: 'Invalid body' }, 400) + const room = options.store.studios.updateRoom(c.req.param('id'), c.get('namespace'), parsed.data) + return room ? c.json({ room }) : c.json({ error: 'Studio not found' }, 404) + }) + + app.delete('/studios/:id', (c) => { + const revoked = options.store.studios.revokeRoom(c.req.param('id'), c.get('namespace')) + return revoked ? c.json({ ok: true }) : c.json({ error: 'Studio not found' }, 404) + }) + + app.delete('/studios/:id/posts', (c) => { + const room = options.store.studios.getRoomById(c.req.param('id'), c.get('namespace')) + if (!room) return c.json({ error: 'Studio not found' }, 404) + const deleted = options.store.studios.clearPosts(room.id) + return c.json({ ok: true, deleted }) + }) + + app.post('/studios/:id/posts/:postId/decision', async (c) => { + const room = options.store.studios.getRoomById(c.req.param('id'), c.get('namespace')) + if (!room) return c.json({ error: 'Studio not found' }, 404) + const body = await c.req.json().catch(() => null) + const parsed = decidePostSchema.safeParse(body) + if (!parsed.success) return c.json({ error: 'Invalid body' }, 400) + if (parsed.data.action === 'dismiss') { + const post = options.store.studios.getPost(c.req.param('postId'), room.id) + if (!post || post.status !== 'open') { + return c.json({ error: 'Open post not found' }, 404) + } + const updated = options.store.studios.decidePost(post.id, room.id, 'dismissed') + return c.json({ post: updated }) + } + + const post = options.store.studios.getPost(c.req.param('postId'), room.id) + if (!post || post.kind !== 'suggestion' || post.status !== 'open') { + return c.json({ error: 'Open suggestion not found' }, 404) + } + + const engine = requireSyncEngine(c, options.getSyncEngine) + if (engine instanceof Response) return engine + const sessionResult = requireSession(c, engine, room.sessionId, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + const text = parsed.data.text ?? post.text + const claimed = options.store.studios.decidePost(post.id, room.id, 'submitted', text) + if (!claimed) return c.json({ error: 'Suggestion already handled' }, 409) + try { + await engine.sendMessage(room.sessionId, { + text: `[Studio suggestion from ${post.authorName}]\n${text}`, + localId: `studio-${post.id}`, + sentFrom: 'webapp', + deliveryMode: 'queue' + }) + return c.json({ post: options.store.studios.getPost(post.id, room.id) }) + } catch (error) { + options.store.studios.reopenPost(post.id, room.id) + return c.json({ error: error instanceof Error ? error.message : 'Failed to submit suggestion' }, 502) + } + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 3444b88c..9f5e9cce 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -26,12 +26,17 @@ import { createUsageRoutes } from './routes/usage' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' +import { createClaudeSessionRoutes } from './routes/claudeSessions' import { createPiSessionRoutes } from './routes/piSessions' +import { createDshSessionRoutes } from './routes/dshSessions' import { createPushRoutes } from './routes/push' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' import { createHubSettingsRoutes } from './routes/hubSettings' import { createWorkGraphRoutes } from './routes/workGraph' +import { createClaudeModelsRoutes } from './routes/claudeModels' +import { createPublicStudioRoutes, createStudioRoutes } from './routes/studios' +import { createSessionShareRoutes } from './routes/sessionShares' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -210,6 +215,8 @@ function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' headers['CDN-Cache-Control'] = 'no-store' headers['Cloudflare-CDN-Cache-Control'] = 'no-store' + } else if (asset.path.startsWith('/studio-assets/')) { + headers['Cache-Control'] = 'public, max-age=31536000, immutable' } return new Response(Bun.file(asset.sourcePath), { @@ -228,6 +235,7 @@ function createWebApp(options: { embeddedAssetMap: Map | null relayMode?: boolean officialWebUrl?: string + dataDir: string }): Hono { const app = new Hono() @@ -278,14 +286,30 @@ function createWebApp(options: { } return next() }) + app.use('/studio-assets/*', async (c, next) => { + c.header('Cache-Control', 'public, max-age=31536000, immutable') + if (acceptsGzip(c.req.header('Accept-Encoding'))) { + return gzipCompress(c, next) + } + return next() + }) app.route('/cli', createCliRoutes(options.getSyncEngine)) app.route('/api', createAuthRoutes(options.jwtSecret, options.store)) app.route('/api', createBindRoutes(options.jwtSecret, options.store)) + app.route('/api', createPublicStudioRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) - app.use('/api/*', createAuthMiddleware(options.jwtSecret)) - app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker)) + app.use('/api/*', createAuthMiddleware(options.jwtSecret, { + isGuestTokenActive: (shareToken) => options.store.sessionShares.getActiveByToken(shareToken) !== null + })) + app.route('/api', createSessionShareRoutes({ store: options.store, jwtSecret: options.jwtSecret, getSyncEngine: options.getSyncEngine })) + app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker, { + isGuestTokenActive: (shareToken) => options.store.sessionShares.getActiveByToken(shareToken) !== null + })) app.route('/api', createSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) @@ -293,17 +317,30 @@ function createWebApp(options: { app.route('/api', createStorageRoutes(configuration.dbPath)) app.route('/api', createHubSettingsRoutes(configuration.dataDir)) app.route('/api', createUsageRoutes(options.store)) + app.route('/api', createStudioRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createGitRoutes(options.getSyncEngine)) // 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。 app.route('/api', createCodexDesktopRoutes({ store: options.store, getSyncEngine: options.getSyncEngine })) + app.route('/api', createClaudeSessionRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createPiSessionRoutes({ store: options.store, getSyncEngine: options.getSyncEngine })) + app.route('/api', createDshSessionRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) + app.route('/api', createClaudeModelsRoutes(options.dataDir)) app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes({ dataDir: configuration.dataDir })) // Path is intentionally NOT `/api/events` — that route is the SSE stream. @@ -337,6 +374,7 @@ from GitHub Pages instead of through the relay tunnel. if (options.embeddedAssetMap) { const embeddedAssetMap = options.embeddedAssetMap const indexHtmlAsset = embeddedAssetMap.get('/index.html') + const studioHtmlAsset = embeddedAssetMap.get('/studio.html') if (!indexHtmlAsset) { app.get('*', (c) => { @@ -353,6 +391,10 @@ from GitHub Pages instead of through the relay tunnel. return await next() } + if (c.req.path.startsWith('/studio/') && studioHtmlAsset) { + return serveEmbeddedAsset(studioHtmlAsset) + } + if (c.req.method !== 'GET' && c.req.method !== 'HEAD') { return await next() } @@ -378,6 +420,7 @@ from GitHub Pages instead of through the relay tunnel. } const { distDir, indexHtmlPath } = findWebappDistDir() + const studioHtmlPath = join(distDir, 'studio.html') if (!existsSync(indexHtmlPath)) { app.get('/', (c) => { @@ -390,6 +433,13 @@ from GitHub Pages instead of through the relay tunnel. } app.use('/assets/*', serveStatic({ root: distDir })) + app.use('/studio-assets/*', serveStatic({ root: distDir })) + + if (existsSync(studioHtmlPath)) { + app.get('/studio/*', async (c) => { + return await serveStatic({ root: distDir, path: 'studio.html' })(c, async () => {}) + }) + } app.use('*', async (c, next) => { if (c.req.path.startsWith('/api')) { @@ -423,6 +473,7 @@ export async function startWebServer(options: { corsOrigins?: string[] relayMode?: boolean officialWebUrl?: string + dataDir: string }): Promise> { const isCompiled = isBunCompiled() const embeddedAssetMap = isCompiled ? await loadEmbeddedAssetMap() : null @@ -435,6 +486,7 @@ export async function startWebServer(options: { vapidPublicKey: options.vapidPublicKey, corsOrigins: options.corsOrigins, embeddedAssetMap, + dataDir: options.dataDir, relayMode: options.relayMode, officialWebUrl: options.officialWebUrl }) diff --git a/scripts/deploy-local-capacity-retry.sh b/scripts/deploy-local-capacity-retry.sh new file mode 100755 index 00000000..72c7ef13 --- /dev/null +++ b/scripts/deploy-local-capacity-retry.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Deploy the freshly built all-in-one binary following docs/local-deployment.md. +set -euo pipefail + +cd "$(dirname "$0")/.." +build=cli/dist-exe/bun-darwin-arm64/hapi +stamp=$(date +%Y%m%d-%H%M%S) +release="$HOME/.hapi/bin/hapi.$stamp-capacity-retry" +stable="$HOME/.hapi/bin/hapi" + +echo "release: $release" + +# Bun's linker signature is not suitable after installation; re-sign once. +codesign --remove-signature "$build" 2>/dev/null || true +codesign --force --sign - "$build" +codesign --verify --deep --strict "$build" +echo "signed build ok" + +# -p preserves the mtime covered by the code-signature cache. +cp -p "$build" "$release" +codesign --verify --deep --strict "$release" +"$release" --version +echo "release verified" + +if [ -L "$stable" ]; then + old_target=$(readlink "$stable") +else + old_target="hapi.bak.$stamp" + mv "$stable" "$HOME/.hapi/bin/$old_target" +fi +ln -sfn "$(basename "$release")" "$stable" +echo "stable link: $stable -> $(readlink "$stable")" +echo "rollback target: $old_target" diff --git a/shared/fixtures/catalogs/modes.json b/shared/fixtures/catalogs/modes.json index e83f63e5..9a6c74c1 100644 --- a/shared/fixtures/catalogs/modes.json +++ b/shared/fixtures/catalogs/modes.json @@ -125,7 +125,28 @@ "tone": "danger" } ], - "dsh": [], + "dsh": [ + { + "label": "Default", + "mode": "default", + "tone": "neutral" + }, + { + "label": "Read Only", + "mode": "read-only", + "tone": "warning" + }, + { + "label": "Workspace Write", + "mode": "workspace-write", + "tone": "warning" + }, + { + "label": "Full Access", + "mode": "danger-full-access", + "tone": "danger" + } + ], "gemini": [ { "label": "Default", diff --git a/shared/src/agentConfig.ts b/shared/src/agentConfig.ts index a5d2ce83..b2f56855 100644 --- a/shared/src/agentConfig.ts +++ b/shared/src/agentConfig.ts @@ -69,6 +69,7 @@ const BUILTIN_DESCRIPTORS: Record = { dsh: fields(MANAGED_PERMISSION), copilot: fields({ ...MODEL, optionSource: 'directory' }, PERMISSION), cursor: fields({ id: 'model', section: 'model', kind: 'dependent-select', optionSource: 'machine', availability: 'both' }, PERMISSION), + dsh: fields({ ...MODEL, optionSource: 'machine' }, PERMISSION), gemini: fields(MODEL, PERMISSION), grok: fields( { ...MODEL, optionSource: 'directory' }, @@ -106,6 +107,7 @@ export function resolveHapiYoloPermissionMode(flavor: AgentFlavor): PermissionMo case 'codex': case 'copilot': case 'cursor': + case 'dsh': case 'gemini': case 'kimi': case 'opencode': diff --git a/shared/src/apiTypes.test.ts b/shared/src/apiTypes.test.ts index 95eaca2e..19a8eed4 100644 --- a/shared/src/apiTypes.test.ts +++ b/shared/src/apiTypes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { ClearOpencodeSessionCallbackRequestSchema, ClearOpencodeSessionResponseSchema, + ListClaudeSessionsRpcResponseSchema, ListCodexSessionsRpcResponseSchema, ListPiSessionsRpcResponseSchema, MessagesQuerySchema, @@ -37,6 +38,36 @@ describe('ListCodexSessionsRpcResponseSchema', () => { }) }) +describe('ListClaudeSessionsRpcResponseSchema', () => { + it('preserves Claude transcript messages when parsing runner RPC responses', () => { + const parsed = ListClaudeSessionsRpcResponseSchema.parse({ + success: true, + sessions: [{ + id: 'claude-session-id', + title: 'Claude Session', + file: '/home/user/.claude/projects/project/session.jsonl', + modifiedAt: 1_000, + messageCount: 1, + messages: [{ + localId: 'claude:claude-session-id:user-1', + createdAt: 900, + content: { + role: 'user', + content: { type: 'text', text: 'hello' }, + meta: { sentFrom: 'cli' } + } + }] + }] + }) + + expect(parsed.success).toBe(true) + if (parsed.success) { + const first = parsed.sessions[0] + expect(first && 'messages' in first ? first.messages : []).toHaveLength(1) + } + }) +}) + describe('ListPiSessionsRpcResponseSchema', () => { it('preserves stable Pi entry and local ids', () => { const parsed = ListPiSessionsRpcResponseSchema.parse({ diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 4a871fa5..63c864fa 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -229,6 +229,57 @@ export type ListCodexSessionsRpcResponse = z.infer export type ArchiveCodexSessionRpcResponse = z.infer +export const ClaudeImportedMessageContentSchema = z.union([ + z.object({ + role: z.literal('user'), + content: z.object({ type: z.literal('text'), text: z.string() }), + meta: z.object({ sentFrom: z.literal('cli') }) + }), + z.object({ + role: z.literal('agent'), + content: z.object({ type: z.literal('output'), data: z.unknown() }), + meta: z.object({ sentFrom: z.literal('cli') }) + }) +]) + +export const ClaudeImportedMessageSchema = z.object({ + localId: z.string().min(1), + createdAt: z.number(), + content: ClaudeImportedMessageContentSchema +}) + +export const ClaudeLocalSessionSummarySchema = z.object({ + id: z.string().min(1), + title: z.string(), + lastUserMessage: z.string().nullable().optional(), + cwd: z.string().nullable().optional(), + file: z.string().min(1), + modifiedAt: z.number(), + model: z.string().nullable().optional(), + messageCount: z.number().int().nonnegative() +}) + +export const ClaudeLocalSessionWithMessagesSchema = ClaudeLocalSessionSummarySchema.extend({ + messages: z.array(ClaudeImportedMessageSchema) +}) + +export const ListClaudeSessionsRpcRequestSchema = z.object({ + cwd: z.string().nullable().optional(), + sessionIds: z.array(z.string().min(1)).optional() +}) + +export const ListClaudeSessionsRpcResponseSchema = z.union([ + z.object({ success: z.literal(true), sessions: z.array(z.union([ClaudeLocalSessionWithMessagesSchema, ClaudeLocalSessionSummarySchema])) }), + z.object({ success: z.literal(false), error: z.string() }) +]) + +export type ClaudeImportedMessageContent = z.infer +export type ClaudeImportedMessage = z.infer +export type ClaudeLocalSessionSummary = z.infer +export type ClaudeLocalSessionWithMessages = z.infer +export type ListClaudeSessionsRpcRequest = z.infer +export type ListClaudeSessionsRpcResponse = z.infer + export const PiImportedMessageContentSchema = CodexImportedMessageSchema export const PiImportedMessageSchema = z.object({ @@ -274,6 +325,51 @@ export type PiLocalSessionWithMessages = z.infer export type ListPiSessionsRpcResponse = z.infer +export const DshImportedMessageSchema = z.object({ + localId: z.string().min(1), + eventSeq: z.number().int().nonnegative(), + createdAt: z.number(), + content: CodexImportedMessageSchema +}) + +export const DshLocalSessionSummarySchema = z.object({ + id: z.string().min(1), + title: z.string(), + lastUserMessage: z.string().nullable().optional(), + cwd: z.string().nullable().optional(), + modifiedAt: z.number(), + model: z.string().nullable().optional(), + reasoningEffort: z.string().nullable().optional(), + messageCount: z.number().int().nonnegative(), + running: z.boolean(), + parentSessionId: z.string().nullable().optional() +}) + +export const DshLocalSessionWithMessagesSchema = DshLocalSessionSummarySchema.extend({ + messages: z.array(DshImportedMessageSchema), + lastEventSeq: z.number().int().nonnegative().nullable() +}) + +export const ListDshSessionsRpcRequestSchema = z.object({ + cwd: z.string().nullable().optional(), + sessionIds: z.array(z.string().min(1)).optional() +}) + +export const ListDshSessionsRpcResponseSchema = z.union([ + z.object({ + success: z.literal(true), + sessions: z.array(z.union([DshLocalSessionWithMessagesSchema, DshLocalSessionSummarySchema])), + sourceUrl: z.string().url() + }), + z.object({ success: z.literal(false), error: z.string() }) +]) + +export type DshImportedMessage = z.infer +export type DshLocalSessionSummary = z.infer +export type DshLocalSessionWithMessages = z.infer +export type ListDshSessionsRpcRequest = z.infer +export type ListDshSessionsRpcResponse = z.infer + export const SessionCollaborationModeRequestSchema = z.object({ mode: CodexCollaborationModeSchema }) @@ -780,6 +876,35 @@ export type CodexModelsResponse = { export type ListCodexModelsResponse = CodexModelsResponse +export type DshReasoningEffortOption = { + id: string + name: string + isDefault: boolean +} + +export type DshModelSummary = { + provider: string + providerName: string + modelId: string + name: string + reasoningEfforts: DshReasoningEffortOption[] +} + +export type DshModelSelection = { + provider: string + modelId: string + reasoningEffort?: string +} + +export type DshModelsResponse = { + success: boolean + availableModels?: DshModelSummary[] + current?: DshModelSelection | null + error?: string +} + +export type ListDshModelsResponse = DshModelsResponse + export type OpencodeModelSummary = { modelId: string name?: string diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index 065d8b27..8c3b27f1 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -137,6 +137,7 @@ describe('convenience functions', () => { expect(supportsModelChange('codex')).toBe(true) expect(supportsModelChange('opencode')).toBe(true) expect(supportsModelChange('cursor')).toBe(true) + expect(supportsModelChange('dsh')).toBe(true) expect(supportsModelChange('pi')).toBe(true) expect(supportsModelChange('kimi')).toBe(true) expect(supportsModelChange(null)).toBe(false) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index 7cd111e0..50125add 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -19,6 +19,7 @@ const FLAVOR_CAPS: Record> = { codex: new Set([Capabilities.ModelChange]), dsh: new Set(), cursor: new Set([Capabilities.ModelChange]), + dsh: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), pi: new Set([Capabilities.ModelChange, Capabilities.Effort]), } @@ -34,6 +35,7 @@ const FLAVOR_LABELS: Record = { codex: 'Codex', dsh: 'DeepSeek Harness', cursor: 'Cursor', + dsh: 'DeepSeek Harness', opencode: 'OpenCode', pi: 'Pi', } diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 4664313b..9798ade5 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -31,6 +31,15 @@ describe('Gemini CLI sunset (read-only, not creatable)', () => { }) describe('getPermissionModesForFlavor', () => { + test('returns DeepSeek Harness native permission presets', () => { + expect(getPermissionModesForFlavor('dsh')).toEqual([ + 'default', + 'read-only', + 'workspace-write', + 'danger-full-access' + ]) + }) + test("returns the conservative Grok modes", () => { expect(getPermissionModesForFlavor('grok')).toEqual([ 'default', @@ -40,10 +49,6 @@ describe('getPermissionModesForFlavor', () => { ]) }) - test("returns no HAPI mode selector for DSH's server-owned permission policy", () => { - expect(getPermissionModesForFlavor('dsh')).toEqual([]) - }) - test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { expect(getPermissionModesForFlavor('pi')).toEqual([]) }) @@ -76,8 +81,6 @@ describe('isPermissionModeAllowedForFlavor', () => { expect(isPermissionModeAllowedForFlavor('acceptEdits', 'grok')).toBe(false) expect(isPermissionModeAllowedForFlavor('auto', 'grok')).toBe(true) expect(isPermissionModeAllowedForFlavor('yolo', 'grok')).toBe(false) - expect(isPermissionModeAllowedForFlavor('read-only', 'dsh')).toBe(false) - expect(isPermissionModeAllowedForFlavor('plan', 'dsh')).toBe(false) }) test("no mode is allowed for pi", () => { @@ -139,10 +142,11 @@ describe('claude auto permission mode', () => { }) describe('isSteeringSupportedForFlavor', () => { - it('supports codex, cursor and pi', () => { + it('supports native steer flavors', () => { + expect(isSteeringSupportedForFlavor('pi')).toBe(true) expect(isSteeringSupportedForFlavor('codex')).toBe(true) expect(isSteeringSupportedForFlavor('cursor')).toBe(true) - expect(isSteeringSupportedForFlavor('pi')).toBe(true) + expect(isSteeringSupportedForFlavor('dsh')).toBe(true) expect(isSteeringSupportedForFlavor('claude')).toBe(false) expect(isSteeringSupportedForFlavor('opencode')).toBe(false) expect(isSteeringSupportedForFlavor(undefined)).toBe(false) @@ -151,9 +155,13 @@ describe('isSteeringSupportedForFlavor', () => { }) describe('isSteeringSupportedForSession', () => { - it('supports codex and pi sessions', () => { - expect(isSteeringSupportedForSession({ flavor: 'codex' })).toBe(true) + it('supports Pi and DeepSeek Harness sessions', () => { expect(isSteeringSupportedForSession({ flavor: 'pi' })).toBe(true) + expect(isSteeringSupportedForSession({ flavor: 'dsh' })).toBe(true) + }) + + it('supports codex sessions', () => { + expect(isSteeringSupportedForSession({ flavor: 'codex' })).toBe(true) }) it('supports Cursor ACP sessions', () => { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index f12b503d..c25ea5ed 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['agy', 'claude', 'codex', 'dsh', 'copilot', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const +export const AGENT_FLAVORS = ['agy', 'claude', 'codex', 'copilot', 'cursor', 'dsh', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) @@ -49,6 +49,9 @@ export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'debug', 'autoReview', 'yolo'] as const export type CursorPermissionMode = typeof CURSOR_PERMISSION_MODES[number] +export const DSH_PERMISSION_MODES = ['default', 'read-only', 'workspace-write', 'danger-full-access'] as const +export type DshPermissionMode = typeof DSH_PERMISSION_MODES[number] + export const PERMISSION_MODES = [ 'default', 'acceptEdits', @@ -59,6 +62,8 @@ export const PERMISSION_MODES = [ 'debug', 'autoReview', 'read-only', + 'workspace-write', + 'danger-full-access', 'safe-yolo', 'yolo', 'request-review', @@ -77,6 +82,8 @@ export const PERMISSION_MODE_LABELS: Record = { autoReview: 'Auto-review', bypassPermissions: 'Yolo', 'read-only': 'Read Only', + 'workspace-write': 'Workspace Write', + 'danger-full-access': 'Full Access', 'safe-yolo': 'Safe Yolo', yolo: 'Yolo', 'request-review': 'Request Review', @@ -95,6 +102,8 @@ export const PERMISSION_MODE_TONES: Record = autoReview: 'warning', bypassPermissions: 'danger', 'read-only': 'warning', + 'workspace-write': 'warning', + 'danger-full-access': 'danger', 'safe-yolo': 'warning', yolo: 'danger', 'request-review': 'neutral', @@ -139,9 +148,6 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'kimi') { return KIMI_PERMISSION_MODES } - if (flavor === 'dsh') { - return [] - } if (flavor === 'copilot') { return COPILOT_PERMISSION_MODES } @@ -157,6 +163,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'cursor') { return CURSOR_PERMISSION_MODES } + if (flavor === 'dsh') { + return DSH_PERMISSION_MODES + } if (flavor === 'pi') { // Pi RPC mode has no runtime permission switching (always auto-approve); // no permission modes are offered. @@ -177,11 +186,6 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: return getPermissionModesForFlavor(flavor).includes(mode) } -/** New Codex sessions use the native shared runtime, without HAPI Safe Yolo. */ -export function getLaunchPermissionModesForFlavor(flavor?: string | null): readonly PermissionMode[] { - return getPermissionModesForFlavor(flavor).filter(mode => flavor !== 'codex' || mode !== 'safe-yolo') -} - export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption[] { return CODEX_COLLABORATION_MODES.map((mode) => ({ mode, @@ -194,15 +198,16 @@ export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption * (per-message "Steer" from the waiting queue), without waiting for full-turn end. * * Steer = soft mid-turn delivery (same idea as Cursor GUI default "Send"): - * - Pi: native steer over the Pi runtime (first-class since #1466) + * - Pi: native queued-message steer * - Codex: app-server `turn/steer` (true mid-turn inject) * - Cursor ACP: concurrent `session/prompt` soft-send (no cancel). Legacy * stream-json Cursor sessions are NOT steerable — gate with * {@link isSteeringSupportedForSession}. + * - DeepSeek Harness: native queued-message steer * * Claude / others: not supported (no reachable soft-steer path) — UI hides Steer. */ -export const STEERING_SUPPORTED_FLAVORS = ['codex', 'cursor', 'pi'] as const +export const STEERING_SUPPORTED_FLAVORS = ['pi', 'codex', 'cursor', 'dsh'] as const export function isSteeringSupportedForFlavor(flavor?: string | null): boolean { return (STEERING_SUPPORTED_FLAVORS as readonly string[]).includes(flavor ?? '') @@ -221,7 +226,7 @@ export function isSteeringSupportedForSession(metadata?: { cursorSessionId?: string | null cursorSessionProtocol?: 'acp' | 'stream-json' | null } | null): boolean { - if (metadata?.flavor === 'codex' || metadata?.flavor === 'pi') { + if (metadata?.flavor === 'pi' || metadata?.flavor === 'codex' || metadata?.flavor === 'dsh') { return true } if (metadata?.flavor !== 'cursor') { diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 325a1586..20ef241d 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -32,9 +32,12 @@ export const RPC_METHODS = { ListPiModelsForMachine: 'listPiModelsForMachine', ListCodexSessions: 'listCodexSessions', ArchiveCodexSession: 'archiveCodexSession', + ListClaudeSessions: 'listClaudeSessions', ListCursorModels: 'listCursorModels', ListPiModels: 'listPiModels', ListPiSessions: 'listPiSessions', + ListDshModels: 'listDshModels', + ListDshSessions: 'listDshSessions', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelVariants: 'listOpencodeModelVariants', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', @@ -45,8 +48,9 @@ export const RPC_METHODS = { ListCopilotModels: 'listCopilotModels', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions', ListAgyModels: 'listAgyModels', - /** Deliver one queued message into the active Pi turn (native steer). */ + /** Deliver one queued message into the active turn (Pi / Codex / Cursor). */ SteerQueuedMessage: 'steer-queued-message', + RetryCodexTurn: 'retry-codex-turn', ForkConversation: 'fork-conversation', RewindConversation: 'rewind-conversation', ClearConversation: 'clear-conversation', diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 9177c805..4bb53bd2 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -82,6 +82,7 @@ export const MetadataSchema = z.object({ // undefined = no migration in flight; banner hidden. // tiann/hapi#873. cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(), + dshSessionId: z.string().optional(), kimiSessionId: z.string().optional(), copilotSessionId: z.string().optional(), piSessionId: z.string().optional(), @@ -137,6 +138,15 @@ export const MetadataSchema = z.object({ // Pi localId → append-only session entry id mapping. Pi entry ids are the // only stable native boundary accepted by its fork API. conversationHistoryEntryIds: z.record(z.string(), z.string().min(1)).optional(), + claudeImportState: z.object({ + state: z.enum(['importing', 'complete', 'failed', 'diverged']), + machineId: z.string(), + claudeSessionId: z.string(), + sourceFile: z.string(), + startedAt: z.number(), + updatedAt: z.number(), + error: z.string().optional() + }).optional(), // Latest Pi append-log entry observed by HAPI. Import uses it as the // incremental cursor so native history already streamed live is not copied twice. piHistoryLeafEntryId: z.string().optional(), @@ -150,6 +160,19 @@ export const MetadataSchema = z.object({ leafEntryId: z.string().nullable().optional(), error: z.string().optional() }).optional(), + dshImportState: z.object({ + state: z.enum(['importing', 'complete', 'failed', 'diverged']), + machineId: z.string(), + dshSessionId: z.string(), + sourceUrl: z.string().url(), + startedAt: z.number(), + updatedAt: z.number(), + lastEventSeq: z.number().int().nonnegative().nullable().optional(), + error: z.string().optional() + }).optional(), + // Latest DSH event observed through import or the live mux. Import uses + // this append-only cursor to avoid copying turns already streamed live. + dshHistoryLastEventSeq: z.number().int().nonnegative().optional(), // Set when native rewind succeeded but HAPI truncate/hydrate failed. conversationHistoryDiverged: z.boolean().optional(), worktree: WorktreeMetadataSchema.optional(), diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index 792dcba8..db101b23 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -141,6 +141,19 @@ describe('toSessionSummary', () => { expect(summary.metadata?.agentSessionId).toBe('pi-session-1') }) + it('includes dshSessionId as the native resume token', () => { + const summary = toSessionSummary(makeSession({ + metadata: { + path: '/proj', + host: 'local', + flavor: 'dsh', + dshSessionId: 'dsh-session-1' + } + })) + + expect(summary.metadata?.agentSessionId).toBe('dsh-session-1') + }) + it.each([ undefined, 'custom', diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index f2d787e6..1850d07e 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -157,6 +157,7 @@ const AGENT_SESSION_ID_FIELD_BY_FLAVOR: Partial
+ diff --git a/web/package.json b/web/package.json index e43113bb..48a4e2b0 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && cp dist/index.html dist/404.html", + "build": "vite build && rm -rf dist-studio && vite build --config vite.studio.config.ts && cp dist-studio/studio.html dist/studio.html && mkdir -p dist/studio-assets && cp -R dist-studio/studio-assets/. dist/studio-assets/ && rm -rf dist-studio", "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "vitest run", diff --git a/web/public/404.html b/web/public/404.html index 5b2afee3..94871b8a 100644 --- a/web/public/404.html +++ b/web/public/404.html @@ -4,8 +4,18 @@ HAPI diff --git a/web/src/App.tsx b/web/src/App.tsx index fde5e489..faef5de8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,24 +9,20 @@ import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' -import { useSessions } from '@/hooks/queries/useSessions' import { useReconnectingState } from '@/hooks/useReconnectingState' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' import { useViewportHeight } from '@/hooks/useViewportHeight' import { useVisibilityReporter } from '@/hooks/useVisibilityReporter' import { queryKeys } from '@/lib/query-keys' -import { refreshAllAgyCatalogs } from '@/lib/agyCatalogAnnouncement' import { AppContextProvider } from '@/lib/app-context' -import { clearMessageWindow, rewindMessageWindow, syncTailMessages } from '@/lib/message-window-store' +import { clearMessageWindow, syncTailMessages } from '@/lib/message-window-store' import { useAppGoBack } from '@/hooks/useAppGoBack' import { useTranslation } from '@/lib/use-translation' -import { translateInputRequestTitle } from '@/lib/input-request-toast' import { VoiceProvider } from '@/lib/voice-context' import { requireHubUrlForLogin } from '@/lib/runtime-config' +import { useSessionGuestAuth } from '@/hooks/useSessionGuestAuth' import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib/appSseSubscriptions' -import { canUseAppBadging, useAppBadge } from '@/hooks/useAppBadge' -import { useAppBadgePreference } from '@/hooks/useAppBadgePreference' import { reconcileQueuedStateAfterConnect } from '@/lib/queued-state-reconciliation' import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' @@ -38,6 +34,7 @@ import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' import { RunnerVersionSkewBanner } from '@/components/RunnerVersionSkewBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' +import { FilePreviewHost } from '@/components/FilePreviewModal' import { PwaUpdateProvider } from '@/lib/pwa-update-context' import { ToastProvider, useToast } from '@/lib/toast-context' import type { SyncEvent } from '@/types/api' @@ -73,6 +70,11 @@ function AppInner() { const [titleSuggestionAvailable, setTitleSuggestionAvailable] = useState(false) const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) + const search = useLocation({ select: (location) => location.search }) + const guestAuth = useSessionGuestAuth(baseUrl, pathname, search) + const effectiveToken = guestAuth.auth?.token ?? token + const effectiveApi = guestAuth.api ?? api + const isSessionGuest = guestAuth.auth !== null const matchRoute = useMatchRoute() const router = useRouter() const { addToast } = useToast() @@ -177,21 +179,7 @@ function AppInner() { const isFirstConnectRef = useRef(true) const baseUrlRef = useRef(baseUrl) const pushPromptedRef = useRef(false) - const { appBadgeEnabled: appBadgePreferenceEnabled } = useAppBadgePreference() - const appBadgeEnabled = Boolean(api && token && appBadgePreferenceEnabled && canUseAppBadging()) - const { - sessions: appBadgeSessions, - isLoading: appBadgeSessionsLoading, - error: appBadgeSessionsError, - } = useSessions(api, { enabled: appBadgeEnabled }) - useAppBadge({ - enabled: appBadgeEnabled, - scope: baseUrl, - sessions: appBadgeSessions, - isLoading: appBadgeSessionsLoading, - hasError: Boolean(appBadgeSessionsError), - }) - const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api) + const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(isSessionGuest ? null : api) useEffect(() => { if (baseUrlRef.current === baseUrl) { @@ -279,8 +267,7 @@ function AppInner() { // freshness window on `useSession`, a previously-viewed session that // received updates during the SSE gap would otherwise serve stale // cached data on remount. See tiann/hapi#884. - queryClient.invalidateQueries({ queryKey: ['session'] }), - refreshAllAgyCatalogs(queryClient) + queryClient.invalidateQueries({ queryKey: ['session'] }) ] const refreshMessages = (selectedSessionId && api) ? syncTailMessages(api, selectedSessionId) @@ -311,11 +298,7 @@ function AppInner() { if (!api || event.sessionId !== selectedSessionId) { return } - if (event.reason === 'rewind' && event.truncateFromLocalId) { - rewindMessageWindow(event.sessionId, event.truncateFromLocalId) - } else { - clearMessageWindow(event.sessionId) - } + clearMessageWindow(event.sessionId) void syncTailMessages(api, event.sessionId) }, [api, selectedSessionId]) @@ -336,10 +319,6 @@ function AppInner() { const translateIncomingToast = useCallback((title: string, body: string): { title: string; body: string } => { const normalizedTitle = title.trim() const normalizedBody = body.trim() - const inputTitle = translateInputRequestTitle(normalizedTitle, t) - if (inputTitle) { - return { title: inputTitle, body: normalizedBody } - } if (normalizedTitle === 'Ready for input') { const waitingMatch = normalizedBody.match(/^(.+)\s+is waiting in\s+(.+)$/i) @@ -396,12 +375,12 @@ function AppInner() { () => getAppSessionSseSubscription(selectedSessionId), [selectedSessionId] ) - const sseEnabled = Boolean(api && token) + const sseEnabled = Boolean(effectiveApi && effectiveToken) const showReconnectingBanner = sseDisconnected && !isSyncing const { subscriptionId: globalSubscriptionId } = useSSE({ - enabled: sseEnabled, - token: token ?? '', + enabled: sseEnabled && !isSessionGuest, + token: effectiveToken ?? '', baseUrl, subscription: globalEventSubscription, scope: 'global', @@ -413,7 +392,7 @@ function AppInner() { const { subscriptionId: sessionSubscriptionId } = useSSE({ enabled: sseEnabled && Boolean(sessionEventSubscription), - token: token ?? '', + token: effectiveToken ?? '', baseUrl, subscription: sessionEventSubscription ?? undefined, scope: 'full', @@ -422,17 +401,36 @@ function AppInner() { }) useVisibilityReporter({ - api, + api: effectiveApi, subscriptionId: globalSubscriptionId, - enabled: sseEnabled + enabled: sseEnabled && !isSessionGuest }) useVisibilityReporter({ - api, + api: effectiveApi, subscriptionId: sessionSubscriptionId, - enabled: sseEnabled && Boolean(sessionEventSubscription) + enabled: sseEnabled && Boolean(sessionEventSubscription) && !isSessionGuest }) + if (isSessionGuest && effectiveApi && effectiveToken) { + return
+ } + + if (pathname.startsWith('/shared-session/')) { + return
+ } + + // Public studio links intentionally do not require a HAPI login token. + // Keep this route outside the authenticated shell; the public API only + // exposes a redacted transcript and the studio's own guest post endpoint. + if (pathname.startsWith('/studio/')) { + return ( +
+ +
+ ) + } + // Loading auth source if (isAuthSourceLoading) { return withPwaBanner( @@ -512,7 +510,8 @@ function AppInner() { } return ( - + + + ) } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 22802f26..23fa6c88 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -57,22 +57,18 @@ describe('ApiClient error mapping', () => { } }) - it('preserves the structured ambiguous-boundary code for Rewind fallbacks', async () => { + it('preserves public session-share verification error codes on 401', async () => { fetchMock.mockResolvedValueOnce( new Response( - JSON.stringify({ - error: 'Rewind is unavailable for this Codex history', - code: 'ambiguous_native_boundary_fork_safe', - hydrateFailed: false - }), - { status: 409, statusText: 'Conflict' } + JSON.stringify({ error: 'Invalid access code or revoked share', code: 'invalid_access_code' }), + { status: 401, statusText: 'Unauthorized' } ) ) - const api = new ApiClient('test-token') - await expect(api.rewindConversation('session-1', 'local-1')).rejects.toMatchObject({ - status: 409, - code: 'ambiguous_native_boundary_fork_safe' + const api = new ApiClient('') + await expect(api.exchangeSessionShare('share-token', '000000')).rejects.toMatchObject({ + status: 401, + code: 'invalid_access_code', }) }) @@ -99,32 +95,6 @@ describe('ApiClient error mapping', () => { } }) - it('returns export warnings and sends explicit confirmation for large exports', async () => { - const warning = { - type: 'warning', - count: 20_001, - limit: 20_000, - estimatedBytes: 12_345_678 - } - const payload = { - schemaVersion: 2, - exportedAt: 1_762_000_000_000, - session: { id: 'session-1' }, - messages: [], - scratchlist: [] - } - fetchMock - .mockResolvedValueOnce(new Response(JSON.stringify(warning), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })) - - const api = new ApiClient('test-token') - await expect(api.getSessionExport('session-1')).resolves.toEqual(warning) - await expect(api.getSessionExport('session-1', { force: true })).resolves.toEqual(payload) - - expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/sessions/session-1/export') - expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/sessions/session-1/export?force=true') - }) - it('loads the Cursor chat store status for the selected session', async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ onDisk: false, store: null }), { status: 200 }) @@ -172,20 +142,6 @@ describe('ApiClient error mapping', () => { expect(fetchMock.mock.calls[0]?.[0]).toBe('/health') }) - it('asks the machine to re-probe agy only when the caller forces a refresh', async () => { - fetchMock.mockImplementation(() => Promise.resolve( - new Response(JSON.stringify({ success: true, availableModels: [] }), { status: 200 }) - )) - - const api = new ApiClient('test-token') - await api.getMachineAgyModels('machine-1') - await api.getMachineAgyModels('machine-1', { refresh: true }) - - expect(fetchMock.mock.calls[0][0]).toContain('/api/machines/machine-1/agy-models') - expect(fetchMock.mock.calls[0][0]).not.toContain('refresh') - expect(fetchMock.mock.calls[1][0]).toContain('/api/machines/machine-1/agy-models?refresh=true') - }) - it('lists and imports Pi sessions through the selected machine', async () => { fetchMock .mockResolvedValueOnce(new Response(JSON.stringify({ success: true, sessions: [], machineId: 'machine-1' }), { status: 200 })) @@ -203,6 +159,37 @@ describe('ApiClient error mapping', () => { }) }) + it('lists and imports Claude sessions through the selected machine', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true, sessions: [], machineId: 'machine-1' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true, results: [], machineId: 'machine-1' }), { status: 200 })) + const api = new ApiClient('test-token') + + await api.getClaudeSessions('/tmp/project', 'machine-1') + await api.importClaudeSessions({ + sessionIds: ['claude-1'], + cwd: '/tmp/project', + machineId: 'machine-1', + model: 'claude-sonnet-4-5', + effort: 'high', + permissionMode: 'bypassPermissions' + }) + + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/claude/sessions?cwd=%2Ftmp%2Fproject&machineId=machine-1') + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/claude/import-sessions') + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ + sessionIds: ['claude-1'], + cwd: '/tmp/project', + machineId: 'machine-1', + model: 'claude-sonnet-4-5', + effort: 'high', + permissionMode: 'bypassPermissions' + }) + }) + }) + it('loads the authoritative queued state for encoded session IDs', async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 625aec75..273bf284 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -9,7 +9,12 @@ import type { CodexDesktopStatusResponse, CodexArchiveSessionResponse, DecryptedMessage, + DshImportSessionsResponse, + DshLocalSessionsResponse, CodexCollaborationMode, + ClaudeImportSessionsRequest, + ClaudeImportSessionsResponse, + ClaudeLocalSessionsResponse, CopilotAgentMode, FileSearchResponse, MachinesResponse, @@ -24,16 +29,23 @@ import type { SkillsResponse, SpawnResponse, VisibilityPayload, - HapiSessionExportResponse, + HapiSessionExport, HubHealthResponse, SessionResponse, SessionTitleSuggestionResponse, - SessionsResponse + SessionsResponse, + StudioAccessMode, + StudioOwnerResponse, + StudioPost, + SessionShare, + SessionShareListItem, + SessionShareCreateResponse, + SessionShareExchangeResponse } from '@/types/api' import type { AgyModelsResponse, - AgentAvailabilityResponse, CodexModelsResponse, + DshModelsResponse, CursorMigrateOutcome, CursorMigrateToAcpRequest, CursorChatStoreStatus, @@ -48,7 +60,6 @@ import type { MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, - OpencodeModelVariantsResponse, OpencodeReasoningEffortResponse, PiModelsResponse, QueuedStateResponse, @@ -266,6 +277,10 @@ export class ApiClient { return await this.request('/api/push/vapid-public-key') } + async getClaudeCustomModels(): Promise<{ models: string[] }> { + return await this.request<{ models: string[] }>('/api/claude/custom-models') + } + async subscribePushNotifications(payload: PushSubscriptionPayload): Promise { await this.request('/api/push/subscribe', { method: 'POST', @@ -297,6 +312,21 @@ export class ApiClient { return await this.request(`/api/pi/sessions${query}`) } + async getClaudeSessions(cwd?: string | null, machineId?: string | null): Promise { + const params = new URLSearchParams() + if (cwd?.trim()) params.set('cwd', cwd.trim()) + if (machineId?.trim()) params.set('machineId', machineId.trim()) + const query = params.size ? `?${params.toString()}` : '' + return await this.request(`/api/claude/sessions${query}`) + } + + async importClaudeSessions(payload: ClaudeImportSessionsRequest): Promise { + return await this.request('/api/claude/import-sessions', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async importPiSessions(payload: { sessionIds: string[]; cwd?: string | null; machineId?: string | null }): Promise { return await this.request('/api/pi/import-sessions', { method: 'POST', @@ -304,6 +334,21 @@ export class ApiClient { }) } + async getDshSessions(cwd?: string | null, machineId?: string | null): Promise { + const params = new URLSearchParams() + if (cwd?.trim()) params.set('cwd', cwd.trim()) + if (machineId?.trim()) params.set('machineId', machineId.trim()) + const query = params.size ? `?${params.toString()}` : '' + return await this.request(`/api/dsh/sessions${query}`) + } + + async importDshSessions(payload: { sessionIds: string[]; cwd?: string | null; machineId?: string | null }): Promise { + return await this.request('/api/dsh/import-sessions', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async archiveCodexSession(sessionId: string, machineId?: string | null): Promise { return await this.request('/api/codex/archive-session', { method: 'POST', @@ -355,13 +400,106 @@ export class ApiClient { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } - async getSessionExport( - sessionId: string, - options?: { force?: boolean; signal?: AbortSignal } - ): Promise { - const query = options?.force ? '?force=true' : '' - return await this.request( - `/api/sessions/${encodeURIComponent(sessionId)}/export${query}`, + async createSessionShare(sessionId: string): Promise { + return await this.request('/api/session-shares', { method: 'POST', body: JSON.stringify({ sessionId }) }) + } + + async getSessionShares(): Promise<{ shares: SessionShareListItem[] }> { + return await this.request<{ shares: SessionShareListItem[] }>('/api/session-shares') + } + + async getSessionShare(sessionId: string): Promise<{ share: SessionShare | null }> { + return await this.request<{ share: SessionShare | null }>(`/api/session-shares/session/${encodeURIComponent(sessionId)}`) + } + + async revokeSessionShare(shareId: string): Promise { + await this.request(`/api/session-shares/${encodeURIComponent(shareId)}`, { method: 'DELETE' }) + } + + async exchangeSessionShare(shareToken: string, accessCode: string): Promise { + // This endpoint intentionally has no authenticated session yet. Keep + // its structured error response intact so the share join screen can + // translate stable codes such as `invalid_access_code` and + // `rate_limited` instead of receiving request()'s generic 401 error. + const res = await fetch(this.buildUrl(`/api/public/session-shares/${encodeURIComponent(shareToken)}/exchange`), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ accessCode }) + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + const code = parseErrorCode(body) + throw new ApiError( + `HTTP ${res.status} ${res.statusText}: ${body}`, + res.status, + code, + body || undefined + ) + } + return await res.json() as SessionShareExchangeResponse + } + + async createStudio(input: { + sessionId: string + title?: string + accessMode?: StudioAccessMode + }): Promise { + return await this.request('/api/studios', { + method: 'POST', + body: JSON.stringify(input) + }) + } + + async getStudio(studioId: string): Promise { + return await this.request(`/api/studios/${encodeURIComponent(studioId)}`) + } + + async getStudioSuggestions(studioId: string, cursor?: { beforeAt: number; beforeId: string }): Promise<{ + items: StudioPost[] + nextCursor: { beforeAt: number; beforeId: string } | null + }> { + const params = cursor + ? `?beforeAt=${encodeURIComponent(cursor.beforeAt)}&beforeId=${encodeURIComponent(cursor.beforeId)}` + : '' + return await this.request(`/api/studios/${encodeURIComponent(studioId)}/suggestions${params}`) + } + + async getStudioForSession(sessionId: string): Promise<{ room: StudioOwnerResponse['room'] | null; posts: StudioPost[] }> { + return await this.request(`/api/studios/session/${encodeURIComponent(sessionId)}`) + } + + async updateStudio( + studioId: string, + input: { title?: string; accessMode?: StudioAccessMode; rotateToken?: boolean } + ): Promise<{ room: StudioOwnerResponse['room'] }> { + return await this.request(`/api/studios/${encodeURIComponent(studioId)}`, { + method: 'PATCH', + body: JSON.stringify(input) + }) + } + + async revokeStudio(studioId: string): Promise { + await this.request(`/api/studios/${encodeURIComponent(studioId)}`, { method: 'DELETE' }) + } + + async clearStudioPosts(studioId: string): Promise<{ deleted: number }> { + return await this.request(`/api/studios/${encodeURIComponent(studioId)}/posts`, { method: 'DELETE' }) + } + + async decideStudioPost( + studioId: string, + postId: string, + input: { action: 'submit' | 'dismiss'; text?: string } + ): Promise<{ post: StudioPost | null }> { + return await this.request( + `/api/studios/${encodeURIComponent(studioId)}/posts/${encodeURIComponent(postId)}/decision`, + { method: 'POST', body: JSON.stringify(input) } + ) + } + + async getSessionExport(sessionId: string, options?: { signal?: AbortSignal }): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/export`, { signal: options?.signal } ) } @@ -559,14 +697,18 @@ export class ApiClient { return response as CancelMessageResponse } - async steerMessage(sessionId: string, messageId: string): Promise { + async steerQueuedMessage(sessionId: string, messageId: string): Promise { const response = await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/steer`, - { method: 'POST' } + { method: 'POST', body: JSON.stringify({}) } ) return response as SteerQueuedMessageResponse } + async steerMessage(sessionId: string, messageId: string): Promise { + return this.steerQueuedMessage(sessionId, messageId) + } + async retryIndeterminateMessage(sessionId: string, messageId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/retry`, @@ -581,14 +723,11 @@ export class ApiClient { }) } - async clearConversation(sessionId: string): Promise<{ sessionId: string }> { - return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/clear`, { method: 'POST' }) - } - - async implementCodexPlan(sessionId: string, planId: string): Promise { - await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/codex/plan/implement`, { - method: 'POST', body: JSON.stringify({ planId }) - }) + async retryCodexTurn(sessionId: string): Promise<{ retried: boolean; error?: string; sessionId?: string }> { + return await this.request<{ retried: boolean; error?: string; sessionId?: string }>( + `/api/sessions/${encodeURIComponent(sessionId)}/retry-codex-turn`, + { method: 'POST' } + ) } async forkConversation(sessionId: string, messageLocalId?: string): Promise<{ sessionId: string }> { @@ -813,12 +952,6 @@ export class ApiClient { ) } - async getMachineAgentAvailability(machineId: string): Promise { - return await this.request( - `/api/machines/${encodeURIComponent(machineId)}/agent-availability` - ) - } - async checkMachinePathsExists( machineId: string, paths: string[] @@ -868,14 +1001,9 @@ export class ApiClient { }) } - async getMachineAgyModels( - machineId: string, - options?: { refresh?: boolean } - ): Promise { - // Without `refresh` the machine may answer from its cached catalog. - const query = options?.refresh ? '?refresh=true' : '' + async getMachineAgyModels(machineId: string): Promise { return await this.request( - `/api/machines/${encodeURIComponent(machineId)}/agy-models${query}` + `/api/machines/${encodeURIComponent(machineId)}/agy-models` ) } @@ -897,6 +1025,18 @@ export class ApiClient { ) } + async getMachineDshModels(machineId: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/dsh-models` + ) + } + + async getSessionDshModels(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/dsh-models` + ) + } + async getSessionOpencodeModels(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/opencode-models` @@ -935,12 +1075,6 @@ export class ApiClient { ) } - async getMachineOpencodeModelVariants(machineId: string, cwd?: string | null): Promise { - return await this.request( - `/api/machines/${encodeURIComponent(machineId)}/opencode-model-variants${cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''}` - ) - } - async getMachineGrokModelsForCwd(machineId: string, cwd: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/grok-models?cwd=${encodeURIComponent(cwd)}` diff --git a/web/src/chat/codexRetry.test.ts b/web/src/chat/codexRetry.test.ts new file mode 100644 index 00000000..1e50ec2f --- /dev/null +++ b/web/src/chat/codexRetry.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { NormalizedMessage } from '@/chat/types' +import { getRetryableCodexTurnMessageId } from '@/chat/codexRetry' + +function event(id: string, message: string): NormalizedMessage { + return { + id, + localId: null, + createdAt: Number(id.replace(/\D/g, '')) || 1, + role: 'event', + isSidechain: false, + content: { type: 'message', message } + } +} + +function ready(id: string): NormalizedMessage { + return { + id, + localId: null, + createdAt: Number(id.replace(/\D/g, '')) || 1, + role: 'event', + isSidechain: false, + content: { type: 'ready' } + } +} + +function user(id: string): NormalizedMessage { + return { + id, + localId: null, + createdAt: Number(id.replace(/\D/g, '')) || 1, + role: 'user', + isSidechain: false, + content: { type: 'text', text: 'new prompt' } + } +} + +function agent(id: string): NormalizedMessage { + return { + id, + localId: null, + createdAt: Number(id.replace(/\D/g, '')) || 1, + role: 'agent', + isSidechain: false, + content: [{ type: 'text', text: 'completed response', uuid: id, parentUUID: null }] + } +} + +describe('getRetryableCodexTurnMessageId', () => { + it('keeps the action only on the latest capacity error', () => { + expect(getRetryableCodexTurnMessageId([ + event('capacity-1', 'Task failed: Selected model is at capacity; retrying same conversation (1/3)'), + event('capacity-2', 'Task failed: Selected model is at capacity; retrying same conversation (2/3)'), + event('capacity-3', 'Task failed: Selected model is at capacity.') + ], false)).toBe('agent-event:capacity-3') + }) + + it('keeps the final failure retryable when Codex appends ready', () => { + expect(getRetryableCodexTurnMessageId([ + event('capacity', 'Task failed: Selected model is at capacity.'), + ready('ready') + ], false)).toBe('agent-event:capacity') + }) + + it('removes the action after a successful retry or a new prompt', () => { + expect(getRetryableCodexTurnMessageId([ + event('capacity', 'Task failed: Selected model is at capacity.'), + agent('answer') + ], false)).toBeNull() + expect(getRetryableCodexTurnMessageId([ + event('capacity', 'Task failed: Selected model is at capacity.'), + user('next') + ], false)).toBeNull() + }) + + it('does not expose retry while the same turn is running', () => { + expect(getRetryableCodexTurnMessageId([ + event('capacity', 'Task failed: Selected model is at capacity.') + ], true)).toBeNull() + }) +}) diff --git a/web/src/chat/codexRetry.ts b/web/src/chat/codexRetry.ts new file mode 100644 index 00000000..16960438 --- /dev/null +++ b/web/src/chat/codexRetry.ts @@ -0,0 +1,53 @@ +import type { AgentEvent, NormalizedMessage } from '@/chat/types' + +const CODEX_CAPACITY_ERROR = 'selected model is at capacity' + +function isCapacityMessage(message: NormalizedMessage): boolean { + if (message.role !== 'event') return false + const event = message.content as AgentEvent + return event.type === 'message' + && typeof event.message === 'string' + && event.message.toLowerCase().includes(CODEX_CAPACITY_ERROR) +} + +/** + * Returns the assistant-ui id of the one capacity error that can still be + * retried. Older capacity notices are historical progress updates. Once a + * later user/agent message arrives, the retry belongs to that newer turn and + * the old error card must become informational only. + * + * `ready` is intentionally allowed after the candidate: Codex also emits a + * ready event after a terminal failure, and that failure is exactly when the + * manual retry affordance is needed. + */ +export function getRetryableCodexTurnMessageId( + messages: readonly NormalizedMessage[], + isThinking: boolean, +): string | null { + if (isThinking) return null + + let candidateIndex = -1 + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (isCapacityMessage(messages[index])) { + candidateIndex = index + break + } + } + if (candidateIndex < 0) return null + + for (let index = candidateIndex + 1; index < messages.length; index += 1) { + const message = messages[index] + if (message.role === 'user' || message.role === 'agent') { + return null + } + if (message.role === 'event' && message.content.type !== 'ready') { + // A later non-capacity event means this failure was superseded by + // another lifecycle transition (for example compaction or an + // unrelated error). Keep the retry action on the current turn + // only, never on stale history. + if (!isCapacityMessage(message)) return null + } + } + + return `agent-event:${messages[candidateIndex].id}` +} diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index 3db9c891..161a7e71 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -130,6 +130,19 @@ describe('getEventPresentation — api-error', () => { expect(getEventPresentation({ type: 'api-error', retryAttempt: 0, maxRetries: 0, error })) .toEqual({ icon: '⚠️', text: 'API error' }) }) + + it('keeps a scheduled final DSH retry in retrying state and includes its reason', () => { + expect(getEventPresentation({ + type: 'api-error', + retryAttempt: 5, + maxRetries: 5, + retryScheduled: true, + error: { message: 'Too many requests (RATE_LIMIT, HTTP 429)' } + })).toEqual({ + icon: '⏳', + text: 'API error: Retrying (5/5) — Too many requests (RATE_LIMIT, HTTP 429)' + }) + }) }) describe('getEventPresentation — limit-warning', () => { diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 5877bbcf..c1d93b09 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -198,7 +198,19 @@ function apiErrorDetail(error: unknown): string | null { export function getEventPresentation(event: AgentEvent): EventPresentation { if (event.type === 'api-error') { - const { retryAttempt, maxRetries } = event as { retryAttempt: number; maxRetries: number } + const { retryAttempt, maxRetries, retryScheduled } = event as { + retryAttempt: number + maxRetries: number + retryScheduled?: boolean + } + if (retryScheduled === true) { + const detail = apiErrorDetail((event as { error?: unknown }).error) + const progress = maxRetries > 0 ? ` (${retryAttempt}/${maxRetries})` : '...' + return { + icon: '⏳', + text: `API error: Retrying${progress}${detail ? ` — ${detail}` : ''}` + } + } if (maxRetries > 0 && retryAttempt >= maxRetries) { return { icon: '⚠️', text: 'API error: Max retries reached' } } diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index 191b76e2..29015856 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -217,6 +217,16 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(visible[0]) && visible[0].defaultOpen).toBe(true) }) + it('keeps every tool call inline when execution-process grouping is disabled', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('inline-read', 'Read', { file_path: '/repo/package.json' }), + makeToolBlock('inline-search', 'Grep', { pattern: 'nativeTitle' }), + ], { hasMoreMessages: false, executionProcessEnabled: false }) + + expect(visible).toHaveLength(2) + expect(visible.every((block) => block.kind === 'tool-call')).toBe(true) + }) + it('keeps structured general Codex commands separate from exploration groups', () => { const read = makeToolBlock('codex-read', 'CodexBash', { command: 'cat package.json', diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index cc6fc6fe..9380534a 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -57,6 +57,8 @@ type ToolGroupingOptions = { hasMoreMessages: boolean previousGroups?: ToolGroupBlock[] codexExplorationCollapsed?: boolean + /** Client presentation preference; false keeps every tool call inline. */ + executionProcessEnabled?: boolean } const PLAN_TOOL_NAMES = new Set([ @@ -274,6 +276,13 @@ export function buildVisibleChatBlocks( visibleBlocks.push(block) continue } + if (options.executionProcessEnabled === false) { + // The execution-process preference is a master presentation + // switch. When disabled, do not create derived ToolGroupCards + // either; show every tool call in its original order. + visibleBlocks.push(block) + continue + } const groupingFamily = getGroupingFamily(block) if (!groupingFamily) { visibleBlocks.push(block) diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index e0bcdd3e..b35ba2f6 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -38,7 +38,7 @@ export type AgentEvent = | { type: 'limit-reached'; endsAt: number; limitType: string } | { type: 'limit-warning'; /** 0–1 ratio (e.g. 0.9 = 90%), integer-precision via CLI pipe format */ utilization: number; endsAt: number; limitType: string } | { type: 'ready' } - | { type: 'api-error'; retryAttempt: number; maxRetries: number; error: unknown } + | { type: 'api-error'; retryAttempt: number; maxRetries: number; error: unknown; retryScheduled?: boolean } | { type: 'turn-duration'; durationMs: number; targetMessageId?: string } | { type: 'turn-summary'; summary: RoundSummary } | { type: 'token-count'; info: unknown; provider?: 'codex'; model?: string | null } diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index d31b8bd4..00445bf1 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -23,6 +23,7 @@ const FLAVOR_LOGOS: Record = { codex: CodexColor, dsh: DeepSeekColor, cursor: CursorMono, + dsh: DeepSeekColor, gemini: GeminiColor, grok: GrokMono, kimi: KimiMono, diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 03d8f4f7..186c3047 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1052,7 +1052,7 @@ export function HappyComposer(props: { const visibleModelEffortOptions = cursorDrillDownVariantOptions ?? modelEffortOptions const codexReasoningEffortOptions = useMemo( - () => agentFlavor === 'codex' || agentFlavor === 'opencode' + () => agentFlavor === 'codex' || agentFlavor === 'dsh' || agentFlavor === 'opencode' ? getCodexComposerReasoningEffortOptions( modelReasoningEffort, agentFlavor, diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index e428e97b..b6d81cc6 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -15,6 +15,7 @@ import { import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage' import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage' import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage' +import { EXECUTION_PROCESS_TOGGLE_EVENT } from '@/components/AssistantChat/messages/executionProcessEvents' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/Spinner' import { useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' @@ -534,6 +535,8 @@ export function HappyThread(props: { onRefresh: () => void onContinuePlan?: () => void onRetryMessage?: (localId: string) => void + onRetryCodexTurn?: () => Promise | void + retryableCodexTurnMessageId?: string | null historyActionPending?: boolean onForkConversation?: (messageLocalId?: string) => Promise onRewindConversation?: (messageLocalId: string) => Promise @@ -626,6 +629,8 @@ export function HappyThread(props: { const shareTurnIdRef = useRef(0) const topSentinelRef = useRef(null) const pendingScrollRef = useRef(null) + const executionProcessToggleAnchorRef = useRef(null) + const executionProcessToggleFrameRef = useRef(null) const isLoadingMoreRef = useRef(props.isLoadingMoreMessages) const hasMoreMessagesRef = useRef(props.hasMoreMessages) const isSyncingTailRef = useRef(props.isSyncingTail) @@ -701,6 +706,42 @@ export function HappyThread(props: { sessionIdRef.current = props.sessionId }, [props.sessionId]) + const clearExecutionProcessToggleFrame = useCallback(() => { + if (executionProcessToggleFrameRef.current !== null) { + window.cancelAnimationFrame(executionProcessToggleFrameRef.current) + executionProcessToggleFrameRef.current = null + } + }, []) + + useEffect(() => { + const handleExecutionProcessToggle = () => { + const viewport = viewportRef.current + if (!viewport) return + + executionProcessToggleAnchorRef.current = captureScrollAnchor(viewport) + autoScrollEnabledRef.current = false + clearExecutionProcessToggleFrame() + executionProcessToggleFrameRef.current = window.requestAnimationFrame(() => { + executionProcessToggleFrameRef.current = window.requestAnimationFrame(() => { + executionProcessToggleFrameRef.current = null + const restoreViewport = viewportRef.current + const anchor = executionProcessToggleAnchorRef.current + executionProcessToggleAnchorRef.current = null + if (!restoreViewport || !anchor) return + restoreScrollAnchor(restoreViewport, anchor) + lastScrollTopRef.current = restoreViewport.scrollTop + }) + }) + } + + window.addEventListener(EXECUTION_PROCESS_TOGGLE_EVENT, handleExecutionProcessToggle) + return () => { + window.removeEventListener(EXECUTION_PROCESS_TOGGLE_EVENT, handleExecutionProcessToggle) + clearExecutionProcessToggleFrame() + executionProcessToggleAnchorRef.current = null + } + }, [clearExecutionProcessToggleFrame]) + const isInitialScrollSettling = useCallback(() => { return initialScrollSessionRef.current === sessionIdRef.current && Date.now() < initialScrollDeadlineRef.current }, []) @@ -1707,6 +1748,8 @@ export function HappyThread(props: { ? props.session.agentState?.codexPlanProposalId : null, onContinuePlan: props.onContinuePlan, onRetryMessage: props.onRetryMessage, + onRetryCodexTurn: props.onRetryCodexTurn, + retryableCodexTurnMessageId: props.retryableCodexTurnMessageId, historyActionPending: props.historyActionPending, onForkConversation: props.onForkConversation, onRewindConversation: props.onRewindConversation, diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx index 794a4851..cb122525 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.test.tsx @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ steerMessage: vi.fn(), resolveSteer: null as ((result: unknown) => void) | null, markMessagesConsumed: vi.fn(), + appendOptimisticMessage: vi.fn(), saveDraft: vi.fn(), messageWindowState: { messages: [] as unknown[] }, })) @@ -47,6 +48,7 @@ vi.mock('@/lib/message-window-store', () => ({ getMessageWindowState: () => mocks.messageWindowState, subscribeMessageWindow: () => () => {}, markMessagesConsumed: mocks.markMessagesConsumed, + appendOptimisticMessage: mocks.appendOptimisticMessage, })) vi.mock('@/hooks/mutations/useCancelQueuedMessage', () => ({ @@ -62,7 +64,13 @@ vi.mock('@/lib/composer-drafts', () => ({ })) vi.mock('@/lib/use-translation', () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string) => ({ + 'queuedMessages.edit': 'Edit queued message', + 'queuedMessages.cancel': 'Cancel queued message', + 'queuedMessages.steerNow': 'Steer now', + })[key] ?? key, + }), })) vi.mock('@/lib/toast-context', () => ({ @@ -113,6 +121,8 @@ function renderQueuedMessage( pendingSchedule={pendingSchedule} pendingScheduleRevision={currentPendingScheduleRevision} onEdit={onEdit} + sessionMetadata={canSteer ? { flavor: 'pi' } : null} + steeringActive={canSteer} canSteer={canSteer} /> @@ -130,6 +140,8 @@ function renderQueuedMessage( pendingSchedule={nextPendingSchedule} pendingScheduleRevision={currentPendingScheduleRevision} onEdit={onEdit} + sessionMetadata={canSteer ? { flavor: 'pi' } : null} + steeringActive={canSteer} canSteer={canSteer} /> @@ -148,6 +160,7 @@ beforeEach(() => { mocks.steerMessage.mockReset() mocks.resolveSteer = null mocks.markMessagesConsumed.mockReset() + mocks.appendOptimisticMessage.mockReset() mocks.saveDraft.mockReset() mocks.messageWindowState = { messages: [] } clearQueuedEditRecovery('session-1') @@ -211,6 +224,56 @@ describe('QueuedMessagesBar layout', () => { }) }) +describe('QueuedMessagesBar steer', () => { + function renderSupportedSteer(options: { steeringActive?: boolean; isThinking?: boolean }) { + const steerQueuedMessage = vi.fn().mockResolvedValue({ + status: 'steered', + localId: 'local-server-message-id', + }) + const api = { steerQueuedMessage } as unknown as ApiClient + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + mocks.messageWindowState = { messages: [makeQueuedMessage()] } + render( + + + + ) + return steerQueuedMessage + } + + it('keeps steer available while a supported remote session is thinking', async () => { + const steerQueuedMessage = renderSupportedSteer({ steeringActive: false, isThinking: true }) + + const steerButton = screen.getByRole('button', { name: 'Steer now' }) + expect(steerButton).not.toBeDisabled() + fireEvent.click(steerButton) + + await waitFor(() => expect(steerQueuedMessage).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(steerButton).not.toBeDisabled()) + }) + + it('starts only one steer operation for synchronous double clicks', async () => { + const steerQueuedMessage = renderSupportedSteer({ steeringActive: true }) + + const steerButton = screen.getByRole('button', { name: 'Steer now' }) + fireEvent.click(steerButton) + fireEvent.click(steerButton) + + await waitFor(() => expect(steerQueuedMessage).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(steerButton).not.toBeDisabled()) + }) +}) + describe('QueuedMessagesBar edit restore', () => { it('keeps a newly typed draft and its schedule when the deferred cancel succeeds', async () => { const scheduledAt = Date.now() + 60_000 @@ -778,35 +841,35 @@ describe('formatScheduledTime', () => { describe('QueuedMessagesBar steer action', () => { // The real useSteerQueuedMessage hook runs here (only the cancel hook is - // module-mocked); pass a fake api whose steerMessage resolves on demand. + // module-mocked); pass a fake API whose steer request resolves on demand. function renderSteerable(canSteer = true) { mocks.steerMessage.mockImplementation(() => new Promise((resolve) => { mocks.resolveSteer = resolve })) - const api = { steerMessage: mocks.steerMessage } as unknown as ApiClient + const api = { steerQueuedMessage: mocks.steerMessage } as unknown as ApiClient const view = renderQueuedMessage(null, null, 0, canSteer, api) return { unmount: view.unmount } } it('shows the Steer button only when canSteer is set and the row is immediate', () => { const immediate = renderSteerable(true) - expect(screen.getByRole('button', { name: 'Steer queued message' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Steer now' })).toBeTruthy() immediate.unmount() renderSteerable(false) - expect(screen.queryByRole('button', { name: 'Steer queued message' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Steer now' })).toBeNull() }) - it('hides the Steer button on future-scheduled rows', () => { - const api = { steerMessage: mocks.steerMessage } as unknown as ApiClient + it('disables the Steer button on future-scheduled rows', () => { + const api = { steerQueuedMessage: mocks.steerMessage } as unknown as ApiClient renderQueuedMessage(Date.now() + 60_000, null, 0, true, api) - expect(screen.queryByRole('button', { name: 'Steer queued message' })).toBeNull() + expect(screen.getByRole('button', { name: 'Steer now' })).toBeDisabled() }) it('calls the steer api with the session and message id', async () => { renderSteerable(true) - fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + fireEvent.click(screen.getByRole('button', { name: 'Steer now' })) await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalledWith('session-1', 'server-message-id')) // Settle the pending mutation so the queued-operation token releases; @@ -820,7 +883,7 @@ describe('QueuedMessagesBar steer action', () => { it('toasts when the steer fails and leaves the row queued', async () => { renderSteerable(true) - fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + fireEvent.click(screen.getByRole('button', { name: 'Steer now' })) await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled()) await act(async () => { mocks.resolveSteer?.({ status: 'failed', error: 'Session is not streaming', localId: 'local-server-message-id' }) @@ -838,7 +901,7 @@ describe('QueuedMessagesBar steer action', () => { it('does not toast on a successful steer (the consumed event clears the row)', async () => { renderSteerable(true) - fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + fireEvent.click(screen.getByRole('button', { name: 'Steer now' })) await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled()) await act(async () => { mocks.resolveSteer?.({ status: 'steered', localId: 'local-server-message-id' }) @@ -851,7 +914,7 @@ describe('QueuedMessagesBar steer action', () => { it('reconciles a stale queued row when the steer returns invoked (missed consumption SSE)', async () => { renderSteerable(true) - fireEvent.click(screen.getByRole('button', { name: 'Steer queued message' })) + fireEvent.click(screen.getByRole('button', { name: 'Steer now' })) await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled()) await act(async () => { mocks.resolveSteer?.({ @@ -861,11 +924,19 @@ describe('QueuedMessagesBar steer action', () => { await Promise.resolve() }) - expect(mocks.markMessagesConsumed).toHaveBeenCalledWith( + expect(mocks.appendOptimisticMessage).toHaveBeenCalledWith( 'session-1', - ['local-server-message-id'], - 5_000, + expect.objectContaining({ + localId: 'local-server-message-id', + invokedAt: 5_000, + status: 'sent', + }) ) - expect(mocks.addToast).not.toHaveBeenCalled() + expect(mocks.addToast).toHaveBeenCalledWith({ + title: 'queuedMessages.steerAlreadyInvoked', + body: '', + sessionId: 'session-1', + url: window.location.href, + }) }) }) diff --git a/web/src/components/AssistantChat/QueuedMessagesBar.tsx b/web/src/components/AssistantChat/QueuedMessagesBar.tsx index 17478eb3..e2775a42 100644 --- a/web/src/components/AssistantChat/QueuedMessagesBar.tsx +++ b/web/src/components/AssistantChat/QueuedMessagesBar.tsx @@ -12,6 +12,7 @@ import { useRetryIndeterminateMessage } from '@/hooks/mutations/useRetryIndeterm import { useTranslation } from '@/lib/use-translation' import { useToast } from '@/lib/toast-context' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' +import { isSteeringSupportedForSession } from '@hapi/protocol' import { formatScheduledTime } from '@/lib/scheduledTime' import { beginQueuedOperation, @@ -44,6 +45,7 @@ function ClockIcon() { ) } +/** Inject-into-stream glyph: mid-turn soft steer (icon-only; label via title/aria). */ function SteerIcon() { return ( @@ -187,21 +203,36 @@ export function computeCanCancel({ /** * Floating bar above the composer showing queued (pending invocation) messages. - * Each item has an edit button (✎) and a cancel button (✕). + * Each item has edit, optional Steer (when the agent supports mid-turn delivery), + * and cancel. * - * Edit = client-side cancel + prefill composer with message text (Codex dialect). + * Edit = client-side cancel + prefill composer with message text. + * Steer = soft mid-turn delivery now (Codex turn/steer / Cursor ACP soft-send). * Cancel = DELETE /sessions/:id/messages/:messageId with optimistic removal. */ export function QueuedMessagesBar({ sessionId, api, + sessionMetadata, + steeringActive, + isThinking, pendingSchedule, pendingScheduleRevision, onEdit, - canSteer, + canSteer: legacyCanSteer, }: { sessionId: string api: ApiClient | null + /** Session metadata — gates the Steer button (flavor + Cursor protocol). */ + sessionMetadata?: { + flavor?: string | null + cursorSessionId?: string | null + cursorSessionProtocol?: 'acp' | 'stream-json' | null + } | null + /** True only while the launcher has an active steerable turn. */ + steeringActive?: boolean + /** Compatibility fallback while the launcher's steering state is propagating. */ + isThinking?: boolean /** Current composer schedule, used only to guard an asynchronous edit restore. */ pendingSchedule: PendingSchedule | null /** Monotonic per-session revision; schedule selections win over an async edit restore. */ @@ -227,6 +258,7 @@ export function QueuedMessagesBar({ const retryMutation = useRetryIndeterminateMessage(api) const { t } = useTranslation() const { addToast } = useToast() + const steeringSupported = Boolean(legacyCanSteer) || isSteeringSupportedForSession(sessionMetadata) const pendingScheduleRef = useRef(pendingSchedule) const pendingScheduleRevisionRef = useRef(pendingScheduleRevision) const composerTextRef = useRef(composerText) @@ -333,17 +365,17 @@ export function QueuedMessagesBar({ return (
- Queued + {t('queuedMessages.title')}
    {queued.map((msg) => { const preview = getQueuedMessagePreview(msg) @@ -351,8 +383,15 @@ export function QueuedMessagesBar({ const editText = getQueuedMessageEditText(preview) const hasAttachments = attachmentNames.length > 0 const localId = msg.localId ?? msg.id - const isPending = cancelMutation.isPending || queuedOperationPending + const isSteerPending = steerMutation.isPending && steerMutation.variables?.localId === localId + const isPending = cancelMutation.isPending || steerMutation.isPending || queuedOperationPending const canCancel = computeCanCancel({ id: msg.id, localId: msg.localId, isPending }) + const isScheduled = msg.scheduledAt != null + const isFutureScheduled = isScheduled && msg.scheduledAt! > Date.now() + const canSteer = steeringSupported + && Boolean(steeringActive || isThinking || legacyCanSteer) + && !isScheduled + && canCancel const handleCancel = () => { if (!canCancel) return @@ -370,27 +409,16 @@ export function QueuedMessagesBar({ }) } - // Steer delivers this message into the active Pi turn. Gated - // on the same server-echo + no-pending-op conditions as - // Edit/Cancel, and never offered for future-scheduled rows - // (the hub rejects those). - const canSteerRow = Boolean( - canSteer - && msg.deliveryState !== 'indeterminate' - && msg.scheduledAt == null - && canCancel - ) - const steerPending = steerMutation.isPending - && steerMutation.variables?.messageId === msg.id const handleSteer = () => { - if (!canSteerRow) return + if (!canSteer) return const token = beginQueuedOperation(sessionId) if (!token) return void steerMutation.mutateAsync({ sessionId, messageId: msg.id, + localId, }).catch(() => { - // useSteerQueuedMessage already toasts the failure. + // useSteerQueuedMessage reports the failure and gives haptic feedback. }).finally(() => { endQueuedOperation(sessionId, token) }) @@ -537,45 +565,20 @@ export function QueuedMessagesBar({ ))}
) : null} - {msg.scheduledAt != null && msg.scheduledAt > Date.now() && ( + {isFutureScheduled && (
- {t('queuedMessages.scheduledFor', { time: formatScheduledTime(msg.scheduledAt) })} + {t('queuedMessages.scheduledFor', { time: formatScheduledTime(msg.scheduledAt!) })}
)}
- {msg.deliveryState === 'indeterminate' ? ( - - ) : null} - {canSteerRow ? ( - - ) : null} + {steeringSupported && ( + + )} + +
+ {props.children} +
+ + ) +} diff --git a/web/src/components/AssistantChat/messages/SystemMessage.tsx b/web/src/components/AssistantChat/messages/SystemMessage.tsx index 5bc0dd3c..a32757af 100644 --- a/web/src/components/AssistantChat/messages/SystemMessage.tsx +++ b/web/src/components/AssistantChat/messages/SystemMessage.tsx @@ -1,10 +1,12 @@ import { MessagePrimitive, useAuiState } from '@assistant-ui/react' +import { useEffect, useState } from 'react' import { getEventPresentation } from '@/chat/presentation' import type { AgentEvent } from '@/chat/types' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { getConversationMessageAnchorId } from '@/chat/outline' import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp' import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { useHappyChatContext } from '@/components/AssistantChat/context' function formatTokenDelta(event: AgentEvent | undefined): string | null { if (!event || event.type !== 'compact-summary') return null @@ -16,6 +18,9 @@ function formatTokenDelta(event: AgentEvent | undefined): string | null { } export function HappySystemMessage() { + const ctx = useHappyChatContext() + const [retrying, setRetrying] = useState(false) + const [retrySeconds, setRetrySeconds] = useState(null) const role = useAuiState((s) => s.message.role) const messageId = useAuiState((s) => s.message.id) const text = useAuiState((s) => { @@ -36,6 +41,24 @@ export function HappySystemMessage() { if (role !== 'system') return null + const canRetryCapacity = text.includes('Selected model is at capacity') + && ctx.retryableCodexTurnMessageId === messageId + && Boolean(ctx.onRetryCodexTurn) + const retryDelayMatch = text.match(/retrying same conversation in (\d+) seconds?/i) + const parsedRetrySeconds = retryDelayMatch ? Number(retryDelayMatch[1]) : null + + useEffect(() => { + if (!canRetryCapacity || parsedRetrySeconds === null) { + setRetrySeconds(null) + return + } + setRetrySeconds(parsedRetrySeconds) + const timer = window.setInterval(() => { + setRetrySeconds((seconds) => seconds === null ? null : Math.max(0, seconds - 1)) + }, 1000) + return () => window.clearInterval(timer) + }, [canRetryCapacity, parsedRetrySeconds]) + // Pi compaction summaries are real content, not status: render them as an // independent block (header with token delta + the summary markdown) // instead of the tiny centered status line used for other events. @@ -62,12 +85,29 @@ export function HappySystemMessage() { return ( -
- - {icon ? : null} - {text} - +
+ + + {text.replace(/; retrying same conversation.*$/i, '')} + {canRetryCapacity ? ( + + ) : }
) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index 537b54bd..a3bb45c0 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -38,14 +38,14 @@ export function HappyUserMessage() { const custom = s.message.metadata.custom as Partial | undefined return custom?.kind === 'cli-output' }) - const steered = useAuiState(({ message }) => ( - message.metadata.custom as Partial | undefined - )?.steered === true) const cliText = useAuiState((s) => { const custom = s.message.metadata.custom as Partial | undefined if (custom?.kind !== 'cli-output') return '' return s.message.content.find((part): part is TextMessagePart => part.type === 'text')?.text ?? '' }) + const steered = useAuiState(({ message }) => ( + message.metadata.custom as Partial | undefined + )?.steered === true) if (role !== 'user') return null const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage) const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined diff --git a/web/src/components/AssistantChat/messages/executionProcessEvents.ts b/web/src/components/AssistantChat/messages/executionProcessEvents.ts new file mode 100644 index 00000000..19b8e739 --- /dev/null +++ b/web/src/components/AssistantChat/messages/executionProcessEvents.ts @@ -0,0 +1,5 @@ +export const EXECUTION_PROCESS_TOGGLE_EVENT = 'hapi-execution-process-toggle' + +export type ExecutionProcessToggleDetail = { + readonly expanded: boolean +} diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index c0b0eea1..08a1f8f8 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -97,6 +97,19 @@ describe('getModelOptionsForFlavor', () => { ]) }) + it('offers configured DeepSeek models in existing Claude sessions', () => { + const options = getModelOptionsForFlavor('claude', 'deepseek-v4-flash[1m]', [ + { value: 'deepseek-v4-flash[1m]', label: 'deepseek-v4-flash[1m]' }, + { value: 'deepseek-v4-pro[1m]', label: 'deepseek-v4-pro[1m]' } + ]) + + expect(options.slice(0, 3)).toEqual([ + { value: null, label: 'Default' }, + { value: 'deepseek-v4-pro[1m]', label: 'deepseek-v4-pro[1m]' }, + { value: 'deepseek-v4-flash[1m]', label: 'deepseek-v4-flash[1m]' } + ]) + }) + it('includes custom Gemini model from env/config in options', () => { const options = getModelOptionsForFlavor('gemini', 'gemini-custom-experiment') expect(options.some((o) => o.value === 'gemini-custom-experiment')).toBe(true) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index d68e5a63..ca631fc2 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -140,7 +140,7 @@ export function getModelOptionsForFlavor( // OpenCode discovers models dynamically via the listOpencodeModels RPC. Until // those options arrive, render an empty list rather than the Claude fallback — // the latter would surface unrelated Claude models in an OpenCode session. - if (flavor === 'opencode') { + if (flavor === 'opencode' || flavor === 'dsh') { return [] } if (flavor === 'cursor') { @@ -207,7 +207,7 @@ export function getNextModelForFlavor( // to the Claude preset cycler — that would post `sonnet`/`opus` into an // OpenCode session and the next turn would attempt `session/set_model` with a // Claude id. Keep the current model unchanged instead. - if (flavor === 'opencode') { + if (flavor === 'opencode' || flavor === 'dsh') { return normalizeCurrentModel(currentModel) } if (flavor === 'cursor') { diff --git a/web/src/components/ClaudeSessionImportDialog.tsx b/web/src/components/ClaudeSessionImportDialog.tsx new file mode 100644 index 00000000..cdf9ba65 --- /dev/null +++ b/web/src/components/ClaudeSessionImportDialog.tsx @@ -0,0 +1,176 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ClaudeLocalSessionSummary } from '@/types/api' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SelectControl } from '@/components/ui/select-control' +import { useTranslation } from '@/lib/use-translation' + +const ALL_DIRECTORIES = '__all__' + +export function ClaudeSessionImportDialog(props: { + isOpen: boolean + onClose: () => void + sessions: ClaudeLocalSessionSummary[] + currentSessionId: string | null + currentWorkDirectory?: string | null + onConfirm: (sessionIds: string[]) => Promise + isPending: boolean + isLoading: boolean +}) { + const { t } = useTranslation() + const [selectedIds, setSelectedIds] = useState([]) + const [directory, setDirectory] = useState(ALL_DIRECTORIES) + const [query, setQuery] = useState('') + + const directories = useMemo( + () => + Array.from( + new Set(props.sessions.map((session) => session.cwd?.trim()).filter((value): value is string => Boolean(value))) + ).sort(), + [props.sessions] + ) + const filtered = useMemo(() => { + const normalized = query.trim().toLowerCase() + return props.sessions.filter((session) => { + if (directory !== ALL_DIRECTORIES && session.cwd !== directory) return false + if (!normalized) return true + return [session.title, session.lastUserMessage, session.cwd, session.id, session.model] + .filter((value): value is string => typeof value === 'string') + .some((value) => value.toLowerCase().includes(normalized)) + }) + }, [directory, props.sessions, query]) + + useEffect(() => { + if (!props.isOpen) return + setSelectedIds(props.currentSessionId ? [props.currentSessionId] : []) + const cwd = props.currentWorkDirectory?.trim() + setDirectory(cwd && directories.includes(cwd) ? cwd : ALL_DIRECTORIES) + setQuery('') + }, [directories, props.currentSessionId, props.currentWorkDirectory, props.isOpen]) + + const toggle = (id: string) => { + if (props.isPending || props.isLoading) return + setSelectedIds((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id])) + } + + return ( + !open && props.onClose()}> + + + {t('claudeImport.dialog.title')} + {t('claudeImport.dialog.description')} + +
+
+ {t('claudeImport.dialog.concurrentWarning')} +
+
+ {t('claudeImport.selectedCount', { n: selectedIds.length })} +
+ + +
+
+ {props.sessions.length > 0 ? ( +
+ setDirectory(event.target.value)} + > + + {directories.map((value) => ( + + ))} + + setQuery(event.target.value)} + /> +
+ ) : null} +
+ {props.isLoading ? ( +
{t('claudeImport.loading')}
+ ) : filtered.length === 0 ? ( +
{t('claudeImport.empty')}
+ ) : ( +
+ {filtered.map((session) => { + const checked = selectedIds.includes(session.id) + return ( + + ) + })} +
+ )} +
+
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/DshSessionImportDialog.tsx b/web/src/components/DshSessionImportDialog.tsx new file mode 100644 index 00000000..d08aa8ac --- /dev/null +++ b/web/src/components/DshSessionImportDialog.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from 'react' +import type { DshLocalSessionSummary } from '@/types/api' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SelectControl } from '@/components/ui/select-control' +import { useTranslation } from '@/lib/use-translation' + +const ALL_DIRECTORIES = '__all__' + +export function DshSessionImportDialog(props: { + isOpen: boolean + onClose: () => void + sessions: DshLocalSessionSummary[] + currentSessionId: string | null + currentWorkDirectory?: string | null + onConfirm: (sessionIds: string[]) => Promise + isPending: boolean + isLoading: boolean +}) { + const { t } = useTranslation() + const [selectedIds, setSelectedIds] = useState([]) + const [directory, setDirectory] = useState(ALL_DIRECTORIES) + const [query, setQuery] = useState('') + + const directories = useMemo(() => Array.from(new Set(props.sessions + .map((session) => session.cwd?.trim()) + .filter((value): value is string => Boolean(value)))).sort(), [props.sessions]) + const filtered = useMemo(() => { + const normalized = query.trim().toLowerCase() + return props.sessions.filter((session) => { + if (directory !== ALL_DIRECTORIES && session.cwd !== directory) return false + if (!normalized) return true + return [session.title, session.lastUserMessage, session.cwd, session.id, session.model] + .filter((value): value is string => typeof value === 'string') + .some((value) => value.toLowerCase().includes(normalized)) + }) + }, [directory, props.sessions, query]) + + useEffect(() => { + if (!props.isOpen) return + setSelectedIds(props.currentSessionId ? [props.currentSessionId] : []) + const cwd = props.currentWorkDirectory?.trim() + setDirectory(cwd && directories.includes(cwd) ? cwd : ALL_DIRECTORIES) + setQuery('') + }, [directories, props.currentSessionId, props.currentWorkDirectory, props.isOpen]) + + const toggle = (id: string) => { + if (props.isPending || props.isLoading) return + setSelectedIds((current) => current.includes(id) + ? current.filter((value) => value !== id) + : [...current, id]) + } + + return ( + !open && props.onClose()}> + + + {t('dshImport.dialog.title')} + {t('dshImport.dialog.description')} + +
+
+ {t('dshImport.dialog.concurrentWarning')} +
+
+ {t('dshImport.selectedCount', { n: selectedIds.length })} +
+ + +
+
+ {props.sessions.length > 0 ? ( +
+ setDirectory(event.target.value)}> + + {directories.map((value) => )} + + setQuery(event.target.value)} + /> +
+ ) : null} +
+ {props.isLoading ? ( +
{t('dshImport.loading')}
+ ) : filtered.length === 0 ? ( +
{t('dshImport.empty')}
+ ) : ( +
+ {filtered.map((session) => { + const checked = selectedIds.includes(session.id) + return ( + + ) + })} +
+ )} +
+
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/FilePreviewModal.tsx b/web/src/components/FilePreviewModal.tsx new file mode 100644 index 00000000..87673604 --- /dev/null +++ b/web/src/components/FilePreviewModal.tsx @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useMemo, useState, type MouseEvent, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { CheckIcon, CloseIcon, CopyIcon } from '@/components/icons' +import { LoadingState } from '@/components/LoadingState' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { useAppContext } from '@/lib/app-context' +import { decodeBase64 } from '@/lib/utils' +import { downloadBase64File } from '@/lib/file-download' +import { queryKeys } from '@/lib/query-keys' +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' +import { useTranslation } from '@/lib/use-translation' +import { + isHtmlFile, + isMarkdownFile, + prepareHtmlDocument, + resolveImageMimeType, +} from '@/lib/file-preview' +import { + FilePreviewContextProvider, + type FilePreviewRequest, +} from '@/lib/file-preview-context' + +function OpenInNewTabIcon() { + return ( + + ) +} + +function BackIcon() { + return ( + + ) +} + +function DownloadIcon() { + return ( + + ) +} + +function openInteractiveHtml(content: string, title: string, backLabel: string): void { + const previewWindow = window.open('about:blank', '_blank') + if (!previewWindow) return + + try { + previewWindow.opener = null + const document = previewWindow.document + document.open() + document.write('') + document.close() + document.title = title + + const header = document.createElement('header') + const backButton = document.createElement('button') + backButton.type = 'button' + backButton.textContent = `← ${backLabel}` + backButton.addEventListener('click', () => { + previewWindow.close() + if (!previewWindow.closed) previewWindow.history.back() + }) + header.appendChild(backButton) + const label = document.createElement('span') + label.textContent = title + header.appendChild(label) + document.body.appendChild(header) + + const frame = document.createElement('iframe') + frame.setAttribute('sandbox', 'allow-scripts allow-forms') + frame.setAttribute('referrerpolicy', 'no-referrer') + frame.srcdoc = prepareHtmlDocument(content) + document.body.appendChild(frame) + } catch { + previewWindow.close() + } +} + +function FilePreviewModal(props: { request: FilePreviewRequest; onClose: () => void }) { + const { api } = useAppContext() + const { t } = useTranslation() + const { copied, copy } = useCopyToClipboard() + const [mode, setMode] = useState<'preview' | 'source'>('preview') + + const fileQuery = useQuery({ + queryKey: queryKeys.sessionFile(props.request.sessionId, props.request.filePath), + queryFn: () => api.readSessionFile(props.request.sessionId, props.request.filePath), + }) + + const fileName = props.request.filePath.split('/').pop() || props.request.filePath + const imageMimeType = useMemo(() => resolveImageMimeType(props.request.filePath), [props.request.filePath]) + const markdownFile = useMemo(() => isMarkdownFile(props.request.filePath), [props.request.filePath]) + const htmlFile = useMemo(() => isHtmlFile(props.request.filePath), [props.request.filePath]) + const decoded = fileQuery.data?.success && fileQuery.data.content + ? decodeBase64(fileQuery.data.content) + : { ok: true, text: '' } + const imageUrl = fileQuery.data?.success && fileQuery.data.content && imageMimeType + ? `data:${imageMimeType};base64,${fileQuery.data.content}` + : null + const previewable = Boolean(imageMimeType || markdownFile || htmlFile) + + useEffect(() => { + setMode('preview') + }, [props.request.filePath]) + + useEffect(() => { + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') props.onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => { + document.body.style.overflow = previousOverflow + window.removeEventListener('keydown', onKeyDown) + } + }, [props.onClose]) + + const handleBackdropClick = useCallback((event: MouseEvent) => { + if (event.target === event.currentTarget) props.onClose() + }, [props.onClose]) + + const openExternal = () => { + if (htmlFile && decoded.text) openInteractiveHtml(decoded.text, fileName, t('file.page.htmlPreviewBackToHapi')) + } + + return ( +
+
event.stopPropagation()} + > +
+ +
+
{fileName}
+
{props.request.filePath}
+
+
+ {htmlFile ? ( + + ) : null} + {fileQuery.data?.success && fileQuery.data.content ? ( + + ) : null} + +
+
+ + {previewable && !imageMimeType ? ( +
+ + +
+ ) : null} + +
+ {fileQuery.isLoading ? ( +
+ ) : fileQuery.error || !fileQuery.data?.success ? ( +
{fileQuery.error instanceof Error ? fileQuery.error.message : fileQuery.data?.error ?? t('file.page.empty')}
+ ) : imageUrl ? ( +
+ {t('file.page.imagePreviewAlt', +
+ ) : mode === 'preview' && markdownFile ? ( +
+ +
+ ) : mode === 'preview' && htmlFile ? ( +
+