Plan: Milestone 12 - Agents and a Native Terminal (from Prompt20).

Steps 1–7 done (2026-09-21) — 5 new browser suites / 36 in the batch, 104 native tests, clippy clean

Claude Code is a provider: Settings → Language model → Claude Code (subscription, no API key) spawns the claude CLI headless in the open folder for every turn (stream-json, --resume for continuity, permission_mode plan by default), its tool activity shows as ▸ Read src/main.rs lines, and a mock CLI covers it in tests. Agent sessions on the server (agent.on_server): a turn runs on the server to the end whether or not a window is open; the transcript is on disk under .moonkale/agent-sessions/; any client that connects later — the phone included — lists the sessions, reads the transcript and answers a pending approval. Connect to Server… on desktop and mobile (URL + token through the client relay). Terminal (Rust): a second terminal extension with no JavaScript of ours — vt100 grid, Dioxus rows, keys in Rust, colours, scrollback, Ctrl+click links — opt-in; with both enabled New Terminal asks which (or terminal.implementation decides). Extensions has its own panel and activity-bar entry.

Steps as executed

#stepoutcomenotes
1ui/extensions_panel.rs: ExtensionsExtension (activity puzzle, order 910), the list shared with Settings → Extensions (ExtensionsList)✅ extensions.mjs: rail entry, panel lists tiers, toggling Flow persists, view.panel.extensions in the paletteui.md
2moonkale-llm feature claude-code: claude_code.rs (spawn, stream-json → events, per-folder (session id, turns) → --resume, 10-min turn timeout, readable errors), Request.cwd, LlmSettings.options, ProviderKind::ClaudeCode; Settings UI (command, permissions, allowed tools); the server jails cwd; tests/mock-claude.sh speaks the protocol✅ 2 unit tests (mapping the probed shapes; resume/restart), 3 mock-CLI tests, claude-code.mjs on the server (▸ line, reply, cwd, permission mode, resume)llm.md; shapes probed against claude 2.1.278 with one real call
3api::agent_sessions: records in memory + <folder>/.moonkale/agent-sessions/<id>.jsonl, a turn per thread (Agent::send with a server tool host over the MCP read-only tools; approvals wait for a client, allow_writes auto-yes, 10-min decline), server functions agent_list/send/events/approve; moonkale_llm::sessions wire types; ext-api::AgentSessions + agent.on_server; agent/server_panel.rs (send, poll, sessions list, approvals)✅ agent-server.mjs: turn runs and is polled in, tool call, page closed → a new page lists the session and shows the transcript, mutating call waits for an approval, Deny reportedapi.md, editor-agent.md
4Connect to Server…: ServerClient in WorkspaceConfig, Workspace::{connect_server, disconnect_server}, server_link, ui/server_dialog.rs, File menu + palette + status item; desktop and mobile (the phone gets api + the relay, server-side shells and git, agent sessions)✅ ext-api/tests/remote_flow.rs::connect_server_… (VirtualDom harness: connect, root opened and recorded, recents untouched, disconnect closes); the phone build compiles, not installed on the device this time. Installed and verified 2026-09-22: the dialog’s connect never ran (P-116, the P-108 pattern again); fixed, ui/tests/server_dialog.rs covers the dialog itself, and the phone then opened the LAN server’s folderdesktop.md, mobile/README.md
5editors/terminal-native: keys.rs (DOM key → bytes), panel.rs (vt100::Parser per session, rows of styled runs, cursor, 16-colour classes + 256/RGB inline, cell size from a 10-M probe via get_client_rect, onresize, wheel scrollback, Ctrl+click on a row → links::find → open)✅ terminal-native.mjs: Ctrl+opens it, prompt renders,echooutput,\e[31m→.mk-tn-fg1, bold, Ctrl+click opens src/main.rs`; 1 unit test for the key tableeditor-terminal-native.md
6Command::NewTerminalIn(&str), terminal.implementation (ask | xterm | native; Settings → Terminal), the frame resolves NewTerminal, ui/terminal_chooser.rs with remember; both panels answer NewTerminalIn✅ last step of terminal-native.mjs (chooser → Rust); terminal.mjs unchanged and passingP-107, P-108 below
7verify, log, vault, catalogue✅ full batch 36/36; this note; Extension Catalogue (two new rows, the provider)

What the user sees

  • Settings → Language model → Provider: Claude Code (subscription, no API key). Command (empty = claude on PATH), Permissions (plan — read and propose only; default; acceptEdits; bypassPermissions), Allowed tools. The Agent panel header reads claude-code · subscription. A turn shows ▸ Read …, ▸ Edit … lines as the CLI works, then its answer; the next message resumes the same CLI session. On the web the CLI runs on the server, in the server’s folder.
  • Run agent turns on the server (Settings → Language model): the Agent panel gets a session picker (● marks a running one) and New; a turn keeps running after the window closes; reopening (or connecting from the phone) shows where it is; a tool that needs approval waits for anyone connected to answer. Off by default on desktop and web (the local agent stays as it was); the phone always uses server sessions when connected.
  • File → Connect to Server… (desktop, phone): URL + token; the server’s folder opens; ⇅ url in the status bar; Disconnect Server returns to local sources.
  • Extensions in the activity bar (below Settings): the same list as Settings → Extensions, on its own page, with the catalogue link.
  • Terminal (Rust): enable it in Extensions; View → New Terminal / Ctrl+then asks *xterm.js* or *Rust* (tick *remember* or set Settings → Terminal → Implementation). The Rust panel: prompt, colours, bold/italic/underline/inverse, scrollback with the wheel, Ctrl+click onpath:line` opens the file, focus ring when it has the keyboard.

Deviations from the plan

  1. Server sessions are opt-in (agent.on_server, default off), not automatic whenever the sources are a server’s: the web client’s local agent has features the server host does not (editor writes, wasm tools, Activity log, Save as page), and four browser suites rely on them. The phone, which has no local provider, always uses server sessions.
  2. The server tool host is read-only (the MCP tools over the registry) plus approval-gated calls that then fail with “not available in a server session” for editor/wasm tools — approvals are exercised, edits are not. Claude Code as the provider edits through its own tools in the server’s folder, which is the case the prompt asked for.
  3. Claude Code’s permission prompts are not routed through Moonkale’s gate (--permission-prompt-tool, Level 3 of Claude Code Extension as written): permission_mode + allowed_tools decide; the CLI runs its own tools under its own rules. The IDE bridge (Level 2) is untouched.
  4. claude-code continuity is --resume when the chat grew by one user message; a cleared or restarted chat starts a new CLI session with the earlier turns rendered into the first prompt.
  5. The native terminal has no mouse selection, no paste, no search, no IME yet; Ctrl+Shift combos are left to the browser (copy/paste); dead keys are ignored. It is opt-in and the chooser names xterm.js first.
  6. The Android build was compiled for the host only in this session; on 2026-09-22 the release APK went onto the phone and Connect to Server… turned out to be dead there and on desktop (P-116) — one spawn that should have been spawn_forever. The remote_flow.rs test exercised connect_server directly and never the dialog; the new harness test drives the dialog’s submit event.
  7. One real call to claude was made to capture the stream-json shapes (a two-word prompt, --max-turns 1); everything else runs against the mock.

Problems hit (→ Problem Log)

  • P-107 two dispatches in one tick: the commands signal holds the latest command only, so show the panel + new terminal lost the first — open_terminal_in shows the panel, yields two renders (YieldNow), then dispatches.
  • P-108 a task spawned from a dialog’s click handler is owned by the dialog’s scope and cancelled when the dialog closes — spawn_forever for work that outlives its dialog.
  • P-109 Signal<Vec<Rc<RefCell<…>>>> and #[component] props: the component macro derives PartialEq on every prop; Rc<RefCell<NativeSession>> needed a by-id PartialEq, AgentSessions (fn pointers) a constant one.
  • P-110 text replacements after cargo fmt: two edits silently missed their target because rustfmt had reflowed the lines in between — the on_server gate and the terminal routing both shipped once as no-ops before the browser suites caught them.

Numbers

  • New code: terminal-native 520 lines, claude_code.rs 500, agent_sessions.rs 470, server_panel.rs 330, extensions_panel.rs 190, dialogs 150.
  • Tests: 104 native (workspace, +12), 36 browser suites (+5: extensions, claude-code, agent-server, terminal-native, and the remote_flow harness gained the server-connect case).
  • The mock claude answers in 10 ms; the real one took 2.6 s of API time for “ok” (27 592 cache-creation tokens: the CLI’s system prompt).