diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce2b14..96da6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,91 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -_Nothing yet._ +### Added + +- **ACP server** — OmniWork now speaks the [Agent Client Protocol](https://agentclientprotocol.com) + (v1) over stdio, so any ACP harness can drive it as a full coding agent: OpenClaw, acpx, Zed, + Neovim. Where the MCP server makes OmniWork a *tool* your agent calls, this makes it an *agent* + another harness runs. + + Register it with `{ "agents": { "omniwork": { "argv": ["npx","-y","omniwork-acp"] } } }` in + `~/.acpx/config.json`, or under `plugins.entries.acpx.config.agents` in `openclaw.json`. + + The agent loop's existing events map onto the protocol directly: text and reasoning stream as + message chunks, every tool call reports `pending → in_progress → completed` with its ACP kind, + and `write_file`/`edit_file` arrive as native diffs rather than a byte count. Gated tool calls + become `session/request_permission`, where "allow always" flips the session to auto mode instead + of silently prompting again. The Agent Deck surfaces as *N* live parallel tool calls, installed + skills are published as slash commands, and `session/load` replays a transcript so a crashed + harness resumes where it left off. Approval modes (`ask`, `edits`, `auto`, `plan`) are exposed as + ACP session modes; `ask` is the default, because in ACP the client owns the permission boundary. +- **Add to chat** — highlight any text in the transcript and a small pill offers to quote it + into the composer, or press `⌘L` / `Ctrl+L`. The quote lands in the prompt as visible `> ` + lines you can read and edit before sending, dimmed by a highlight layer behind the + textarea. +- **Collapsed pastes** — paste (or quote) more than a few lines and the prompt shows + `[Pasted text #1 +322 lines]` instead of a wall of text, the way Claude Code does. The body + is held aside and spliced back in on the way to the model. + + The token is a single unit: one Backspace or Delete touching it removes the whole block + rather than nibbling it into unmatchable debris, so the keypress right after pasting undoes + the paste. Editing a token apart also drops its body — what gets sent is always what you + can see. + + Once submitted the block opens under the prompt line, capped and scrollable like a tool + card, with a click to collapse it again. The bodies ride on the transcript event, so they + are still there after a restart. +- **Copy on select** — highlighting text in the transcript copies it to the clipboard + immediately, the way a terminal does. Scoped to agent output: selections inside the + composer, project fields, and modals are left alone, since selecting there means editing. + Toggle with `/copy on|off`; the setting persists in `prefs.json`. +### Changed + +- **Delegation is substantially faster.** Three things were making an MCP `delegate` call take + far longer than the work justified: + + The agent tried a streaming request first and fell back to a non-streaming one whenever the + stream came back empty — which free `auto` routing does often. Nobody reads the token stream + during delegation, so that fallback was buying nothing and costing a second full request on + every affected step, up to 40 steps per task. Headless callers now make exactly one request per + step, and parallel subagents never stream either, since only their tool labels and final summary + are ever read. + + The OmniRoute gateway also booted lazily on the *first* delegate call, so the caller paid the + entire cold start — tens of seconds, or an engine download on lite builds — before any work + started. It now starts in the background at `initialize`, overlapping with the host reading the + tool list. + + Finally, delegation reported nothing until it was completely finished, so a slow call and a hung + one looked identical. Delegate calls now emit `notifications/progress` per step and per finished + subagent, and a stalled turn is cut off at `OMNIWORK_DELEGATE_TIMEOUT_MS` (default 10 min) and + returns its partial work instead of hanging until the client gives up. +- Both headless servers (MCP and ACP) share `electron/headless.js` for gateway, skills, memory, + and project wiring, and both honour `OMNIWORK_BASE_URL` / `OMNIWORK_API_KEY` to run against an + OpenAI-compatible endpoint other than the bundled gateway. + +### Fixed + +- **A wedged gateway no longer takes the app down with it.** If a previous run left an OmniRoute + process bound to port 20128 but no longer answering, every subsequent boot failed — and failed + slowly, then reported only `Gateway exited (code 1)`. + + Two things were wrong. The health-check loop called `fetch` with no per-request timeout, so a + process that accepts the connection and never replies parked each poll for undici's 300 s + default; the loop's own 90 s deadline is only evaluated between iterations, so it never fired. + And a child that died instantly on `EADDRINUSE` was indistinguishable from one still starting + up, so the loop kept polling a port owned by somebody else. + + Health checks are now bounded per request, and a dead child ends the wait immediately rather + than burning the full budget. The busy port is detected before spawning, by asking the OS + whether we can bind it: since 0.11.1 the child's output goes to `/dev/null` so a detached + gateway can outlive us without taking EPIPE, which means its `EADDRINUSE` is no longer ours to + read. When the port is held, the sidecar re-probes patiently first — the holder is often a + healthy gateway that was merely slower than the 2 s startup probe — and adopts it if it wakes + up. If it really is wedged, the sidecar starts on an OS-assigned free port instead. + + It deliberately does not kill the holder. That process may not belong to OmniWork, and taking a + port by force isn't a decision to make on the user's behalf. ## [0.11.1] — 2026-07-29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66432aa..5363a6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,11 +88,17 @@ npm run test:smoke # end-to-end: gateway -> agent -> file written on disk npm run test:cowork # parallel sessions npm run test:features # streaming, undo, approval npm run test:persist # save/restore across restarts +npm run test:selection # copy-on-select, "add to chat", collapsed pastes (real renderer) ``` `test:boot` is the CI gate. The rest need network access and live free-provider availability, so they are run locally before a release. +`test:selection` is the exception: it launches the app and drives the page through +`OMNIWORK_UI_TEST`, so it needs a display but no network. It also reads the system +clipboard to prove copy-on-select really wrote to it — that assertion is skipped +off macOS/Linux. + ## Building installers ```bash diff --git a/README.md b/README.md index fa7cc83..c508552 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ delegate** *inside* Claude Code or Codex. | 🌐 **Browsing built in** | `web_search` + `browse_page` render in a real Chromium window — JavaScript pages work, no API key. | | 🔌 **MCP connections** | Plug in tools (filesystem, GitHub, Postgres, Slack…) via any stdio MCP server. Add from the UI. | | 🪙 **Delegate tool** | OmniWork is *also* an MCP server — let Claude Code / Codex offload grunt work to its free models. | +| 🔗 **ACP agent** | Speaks Agent Client Protocol, so OpenClaw / acpx / Zed can drive OmniWork as a full coding agent. | +| ✂️ **Select to quote** | Highlighting output copies it instantly; a pill (or `⌘L`) quotes it into the prompt as `> ` lines. | +| 📋 **Collapsed pastes** | Paste 300 lines and the prompt shows `[Pasted text #1 +322 lines]` — the model still gets all of it. | | 🔒 **100% local** | Everything runs on your machine. The gateway never phones home. | | 🧩 **MIT, hackable** | Plain CommonJS, no build step for the UI. Fork it, ship it, sell it. | @@ -148,6 +151,47 @@ matters — free models drift on instruction details. Treat the returned summary as a **claim, not evidence**, and verify before calling the work done. `npm run connect` installs this guidance so your agent applies it automatically. +## Drive OmniWork from OpenClaw, Zed, or any ACP harness 🔌 + +The MCP server makes OmniWork a *tool* your agent calls. The **ACP server** makes it a full +**coding agent** that another harness drives — OpenClaw, [acpx](https://github.com/openclaw/acpx), +Zed, Neovim, anything that speaks the +[Agent Client Protocol](https://agentclientprotocol.com). The harness owns the UI, the approval +prompts, and the transcript; OmniWork does the work on free models. + +```json +// ~/.acpx/config.json or /.acpxrc.json +{ "agents": { "omniwork": { "argv": ["npx", "-y", "omniwork-acp"] } } } +``` + +```json +// openclaw.json +{ "plugins": { "entries": { "acpx": { "enabled": true, "config": { + "agents": { "omniwork": { "command": "npx", "args": ["-y", "omniwork-acp"] } } +} } } } } +``` + +Then `acpx omniwork "fix the flaky test"`, or spawn it as an OpenClaw subagent. + +What the harness gets: + +| | | +|---|---| +| **Streaming output** | Text, reasoning, and per-tool status as they happen | +| **Native diffs** | `write_file` / `edit_file` arrive as ACP diffs, not "wrote 412 bytes" | +| **Permission prompts** | Every gated tool call becomes `session/request_permission` — "allow always" flips the session to auto | +| **The Agent Deck** | `spawn_subagents` shows up as *N* live parallel tool calls, not one opaque block | +| **Skills as slash commands** | Installed skills are published via `available_commands_update` | +| **Modes** | `ask` (default), `edits`, `auto`, `plan` — switch with `acpx omniwork set-mode plan` | +| **Resumable sessions** | `session/load` replays the transcript, so a crashed harness picks up where it left off | + +Modes default to **ask** because ACP's whole point is that the client owns the permission +boundary. Set `OMNIWORK_ACP_MODE=auto` if you'd rather it run unattended. + +By default OmniWork runs on its own bundled OmniRoute gateway (free models). To spend a different +provider's budget instead, point it elsewhere — `OMNIWORK_BASE_URL`, `OMNIWORK_API_KEY`, and +`OMNIWORK_MODEL` apply to both the ACP and MCP servers. + ## Build from source Requires **Node.js 22+** (24 recommended). @@ -201,6 +245,7 @@ Locally built `.app`s are unsigned — same right-click → Open dance as above. OmniRoute ──► 278+ providers (free tier default) mcp-server.js ──► Claude Code / Codex delegate here + acp-server.js ──► OpenClaw / acpx / Zed drive OmniWork here ``` - On boot, `electron/sidecar.js` runs OmniRoute's prebuilt server on a **bundled Node** runtime @@ -209,7 +254,9 @@ Locally built `.app`s are unsigned — same right-click → Open dance as above. - `electron/agent.js` runs an OpenAI-compatible tool-use loop; `spawn_subagents` fans out; MCP tools merge in namespaced as `mcp____`. - `electron/sessions.js` runs many agents in parallel; `electron/mcp.js` is the MCP client; - `electron/mcp-server.js` exposes OmniWork *as* an MCP server (the delegate tool). + `electron/mcp-server.js` exposes OmniWork *as* an MCP server (the delegate tool), and + `electron/acp-server.js` exposes it as an ACP agent. Both are headless stdio servers sharing + `electron/headless.js` for gateway, skills, and memory wiring. - Agent file tools are confined to the workspace folder you pick. See [SECURITY.md](SECURITY.md) for what that does and does not cover. @@ -222,7 +269,11 @@ provider keys (stored encrypted, locally), or pick a specific model in the statu |-----|---------|---------| | `OMNIWORK_GATEWAY_PORT` | `20128` | Gateway port | | `OMNIWORK_WORKSPACE` | — | Open a folder on launch | -| `OMNIWORK_MODEL` | `auto` | Pin a model (delegate server) | +| `OMNIWORK_MODEL` | `auto` | Pin a model (MCP + ACP servers) | +| `OMNIWORK_BASE_URL` | — | Run headless servers against another OpenAI-compatible endpoint | +| `OMNIWORK_API_KEY` | `omniwork` | Key for `OMNIWORK_BASE_URL` | +| `OMNIWORK_ACP_MODE` | `ask` | Starting approval mode for ACP sessions | +| `OMNIWORK_DELEGATE_TIMEOUT_MS` | `600000` | Backstop before a stalled delegate returns partial work | | `OMNIWORK_NODE` | — | Node binary used to run the gateway in dev | | `OMNIWORK_NODE_VERSION` | build host's | Node version staged into packaged builds | | `OMNIWORK_DEV` | — | Devtools + verbose logs | @@ -240,6 +291,8 @@ electron/ tools.js file/shell/web tools (workspace-confined) mcp.js MCP client (connect external tool servers) mcp-server.js MCP server (delegate tool for Claude Code / Codex) + acp-server.js ACP agent (OpenClaw / acpx / Zed drive OmniWork) + headless.js shared bootstrap for both stdio servers preload.js contextIsolation-safe IPC bridge renderer/ the terminal UI (index.html, styles.css, app.js) scripts/ @@ -248,7 +301,7 @@ scripts/ connect.js registers the MCP server and installs delegate guidance doctor.js setup verification + repair gen-icon.js dependency-free app-icon generator -test/ boot · smoke · cowork · features · persist +test/ boot · smoke · cowork · features · persist · mcp · acp · sidecar ``` ## Documentation diff --git a/electron/acp-server.js b/electron/acp-server.js new file mode 100644 index 0000000..5fd0f80 --- /dev/null +++ b/electron/acp-server.js @@ -0,0 +1,508 @@ +#!/usr/bin/env node +"use strict"; +// OmniWork as an ACP AGENT (Agent Client Protocol v1). +// +// Where mcp-server.js lets a premium agent delegate *into* OmniWork as a tool, +// this exposes OmniWork as a first-class coding agent that any ACP client can +// drive: OpenClaw, acpx, Zed, Neovim — the harness owns the UI, OmniWork does +// the work on free models via the bundled OmniRoute gateway. +// +// Wire it into acpx (~/.acpx/config.json or /.acpxrc.json): +// { "agents": { "omniwork": { "argv": ["npx", "-y", "omniwork-acp"] } } } +// …or OpenClaw (openclaw.json): +// plugins.entries.acpx.config.agents.omniwork = { command: "npx", args: ["-y","omniwork-acp"] } +// +// Speaks JSON-RPC 2.0 over stdio (newline-delimited), bidirectionally: unlike +// the MCP server we also send *requests* to the client (session/request_permission), +// so outbound ids get a pending map. +// +// stdout is reserved for the protocol; all logging goes to stderr. + +const fs = require("node:fs"); +const path = require("node:path"); +const { ensureShellPath } = require("./shell-path"); +const { Agent } = require("./agent"); +const { agentEnv, ensureGateway, prewarmGateway, DATA_DIR, SKILLS_DIR } = require("./headless"); +const skillsApi = require("./skills"); + +ensureShellPath(); // ACP clients can launch us with a minimal environment too + +const PROTOCOL_VERSION = 1; +const VERSION = require("../package.json").version; +const log = (...a) => process.stderr.write("[omniwork-acp] " + a.join(" ") + "\n"); +const STORE_DIR = path.join(DATA_DIR, "acp-sessions"); + +// OmniWork's approval modes, as ACP session modes. `ask` is the default: the +// whole point of ACP is that the client owns the permission boundary, so we let +// it prompt rather than silently running commands in someone's editor. +const MODES = [ + { id: "ask", name: "Ask", description: "Ask before file edits and commands." }, + { id: "edits", name: "Accept edits", description: "Auto-accept file edits; ask before commands." }, + { id: "auto", name: "Auto", description: "Run everything without asking." }, + { id: "plan", name: "Plan", description: "Explore and design only — no changes." }, +]; +const DEFAULT_MODE = MODES.some((m) => m.id === process.env.OMNIWORK_ACP_MODE) + ? process.env.OMNIWORK_ACP_MODE + : "ask"; + +const TOOL_KIND = { + list_dir: "read", read_file: "read", read_knowledge: "read", use_skill: "read", + write_file: "edit", edit_file: "edit", + run_command: "execute", + web_search: "search", + web_fetch: "fetch", browse_page: "fetch", open_url: "fetch", + spawn_subagents: "think", +}; + +function toolTitle(name, args = {}) { + switch (name) { + case "run_command": return String(args.command || "").slice(0, 120) || "run command"; + case "read_file": return `Read ${args.path}`; + case "write_file": return `Write ${args.path}`; + case "edit_file": return `Edit ${args.path}`; + case "list_dir": return `List ${args.path || "."}`; + case "web_search": return `Search "${String(args.query || "").slice(0, 80)}"`; + case "web_fetch": case "browse_page": return `Fetch ${args.url}`; + case "open_url": return `Open ${args.url}`; + case "spawn_subagents": return `Fan out to ${(args.tasks || []).length} subagents`; + case "use_skill": return `Skill: ${args.name}`; + case "save_skill": return `Save skill: ${args.name}`; + case "save_memory": return `Remember: ${args.title || ""}`; + case "install_skills": return `Install skills from ${args.source}`; + default: return name; + } +} + +// ACP renders `diff` tool content natively, so edits show up as real diffs in the +// client instead of "Wrote 412 bytes". Computed at tool_call time — before the +// write lands — because that's the only moment the old content still exists. +function diffFor(workspace, name, args = {}) { + if (name !== "write_file" && name !== "edit_file") return null; + try { + if (!args.path) return null; + const abs = path.resolve(workspace, args.path); + const old = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null; + if (name === "write_file") return [{ type: "diff", path: abs, oldText: old, newText: String(args.content ?? "") }]; + if (old != null && args.old_string != null && old.includes(args.old_string)) { + return [{ type: "diff", path: abs, oldText: old, newText: old.replace(args.old_string, args.new_string ?? "") }]; + } + } catch {} + return null; +} + +function locationsFor(workspace, args = {}) { + if (!args.path) return undefined; + try { return [{ path: path.resolve(workspace, args.path) }]; } catch { return undefined; } +} + +// ContentBlock[] → the (text, images) pair Agent.send expects. +function decodePrompt(blocks) { + const parts = []; + const images = []; + for (const b of blocks || []) { + if (!b || typeof b !== "object") continue; + if (b.type === "text") parts.push(b.text || ""); + else if (b.type === "image" && b.data) images.push(`data:${b.mimeType || "image/png"};base64,${b.data}`); + else if (b.type === "resource_link") parts.push(`${b.name || b.uri} (${b.uri})`); + else if (b.type === "resource" && b.resource) { + const r = b.resource; + if (typeof r.text === "string") parts.push(`\n--- ${r.uri || "attached file"} ---\n${r.text}\n---`); + else if (r.uri) parts.push(String(r.uri)); + } + } + return { text: parts.join("\n").trim(), images }; +} + +// ── JSON-RPC plumbing (bidirectional) ───────────────────────────── +let outSeq = 0; +const pendingOut = new Map(); // our request id -> {resolve, reject} + +function write(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } +function notify(method, params) { write({ jsonrpc: "2.0", method, params }); } +function callClient(method, params) { + const id = `ow${++outSeq}`; + return new Promise((resolve, reject) => { + pendingOut.set(id, { resolve, reject }); + write({ jsonrpc: "2.0", id, method, params }); + }); +} + +class RpcError extends Error { + constructor(code, message) { super(message); this.code = code; } +} + +// ── sessions ────────────────────────────────────────────────────── +const sessions = new Map(); +let sessionSeq = 0; +let msgReplaySeq = 0; + +function sessionFile(id) { return path.join(STORE_DIR, id + ".json"); } + +function persist(sess) { + try { + fs.mkdirSync(STORE_DIR, { recursive: true }); + fs.writeFileSync(sessionFile(sess.id), JSON.stringify({ + id: sess.id, cwd: sess.cwd, mode: sess.mode, + messages: sess.agent.messages.slice(-200), + })); + } catch (e) { log("persist failed:", e.message); } +} + +async function buildSession({ id, cwd, mode, messages }) { + const gw = await ensureGateway(log); + const sess = { + id, cwd, mode: mode || DEFAULT_MODE, + sink: () => {}, // replaced per turn + approve: async () => false, // replaced per turn + pendingPermissions: new Set(), + agent: null, + }; + sess.agent = new Agent({ + baseUrl: gw.baseUrl, + apiKey: gw.apiKey, + model: process.env.OMNIWORK_MODEL || "auto", + workspace: cwd, + canSpawn: true, + approvalMode: sess.mode, + approver: (callId, name, args, preview) => sess.approve(callId, name, args, preview), + emit: (type, payload) => sess.sink(type, payload), + ...agentEnv(cwd), + }); + if (Array.isArray(messages) && messages.length) sess.agent.messages = messages; + sessions.set(id, sess); + return sess; +} + +// Skills become ACP slash commands, so `/deep-research` in the client's prompt +// box is the same skill the desktop app exposes. +function pushCommands(sess) { + let list = []; + try { list = skillsApi.listSkills(SKILLS_DIR, sess.cwd) || []; } catch {} + notify("session/update", { + sessionId: sess.id, + update: { + sessionUpdate: "available_commands_update", + availableCommands: list.slice(0, 100).map((s) => ({ + name: s.name, + description: (s.description || `Run the ${s.name} skill.`).slice(0, 200), + input: { hint: "optional extra instructions" }, + })), + }, + }); +} + +// ── the prompt turn ─────────────────────────────────────────────── +async function runTurn(sess, blocks) { + const { text, images } = decodePrompt(blocks); + if (!text && !images.length) return { stopReason: "end_turn" }; + + const sid = sess.id; + const up = (update) => notify("session/update", { sessionId: sid, update }); + const tools = new Map(); // OmniWork call id -> { acpId, name, args, diff } + let toolSeq = 0; + let msgSeq = 0; + let messageId = `m${Date.now().toString(36)}_0`; + let stopReason = "end_turn"; + let failure = null; + + sess.sink = (type, p) => { + try { + switch (type) { + // Each model round-trip starts a new assistant message. + case "thinking": + messageId = `m${Date.now().toString(36)}_${++msgSeq}`; + break; + + case "assistant_delta": + if (p.chunk) up({ sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text: p.chunk } }); + break; + + // `assistant` repeats what the deltas already streamed — dropping it + // avoids showing every reply twice. + case "assistant": + break; + + case "system": + up({ sessionUpdate: "agent_message_chunk", messageId: `sys${Date.now().toString(36)}`, content: { type: "text", text: String(p.content || "") } }); + break; + + case "tool_call": { + const acpId = `t${++toolSeq}`; + const diff = diffFor(sess.cwd, p.name, p.args); + tools.set(p.id, { acpId, name: p.name, args: p.args, diff }); + up({ + sessionUpdate: "tool_call", + toolCallId: acpId, + title: toolTitle(p.name, p.args), + kind: TOOL_KIND[p.name] || "other", + status: "pending", + rawInput: p.args, + ...(diff ? { content: diff } : {}), + ...(locationsFor(sess.cwd, p.args) ? { locations: locationsFor(sess.cwd, p.args) } : {}), + }); + break; + } + + case "tool_stream": { + const t = tools.get(p.id); + if (!t || !p.chunk) break; + up({ + sessionUpdate: "tool_call_update", toolCallId: t.acpId, status: "in_progress", + content: [{ type: "content", content: { type: "text", text: String(p.chunk) } }], + }); + break; + } + + case "tool_result": { + const t = tools.get(p.id); + if (!t) break; + const out = String(p.result ?? ""); + const failed = /^(Error in |Failed to |❌ Denied|⏸ Plan mode|Search failed|Browse failed|Install failed)/.test(out); + up({ + sessionUpdate: "tool_call_update", toolCallId: t.acpId, + status: failed ? "failed" : "completed", + content: t.diff || [{ type: "content", content: { type: "text", text: out.slice(0, 20000) } }], + rawOutput: { output: out.slice(0, 20000) }, + }); + break; + } + + // The Agent Deck: each parallel subagent becomes its own ACP tool call, + // so the client shows N live workers instead of one opaque block. + case "subagent": { + if (p.kind === "start") { + const acpId = `t${++toolSeq}`; + tools.set("sub:" + p.subId, { acpId, name: "subagent", args: {} }); + up({ sessionUpdate: "tool_call", toolCallId: acpId, title: `subagent: ${p.title}`, kind: "think", status: "in_progress" }); + } else if (p.kind === "tool" || p.kind === "text" || p.kind === "error") { + const t = tools.get("sub:" + p.subId); + if (!t) break; + const line = p.kind === "tool" ? `→ ${p.tool}` : p.kind === "error" ? `error: ${p.message}` : String(p.snippet || ""); + up({ + sessionUpdate: "tool_call_update", toolCallId: t.acpId, status: "in_progress", + content: [{ type: "content", content: { type: "text", text: line + "\n" } }], + }); + } else if (p.kind === "done") { + const t = tools.get("sub:" + p.subId); + if (t) up({ sessionUpdate: "tool_call_update", toolCallId: t.acpId, status: "completed" }); + } + break; + } + + case "context": + up({ + sessionUpdate: "usage_update", + used: Math.round(((p.pct || 0) / 100) * sess.agent.contextTokens), + size: sess.agent.contextTokens, + }); + break; + + case "done": stopReason = "end_turn"; break; + case "aborted": stopReason = "cancelled"; break; + case "error": + failure = String(p.message || "agent error"); + // "max steps" is a turn-budget stop, not a failure — ACP has a reason for it. + if (/max steps/i.test(failure)) { stopReason = "max_turn_requests"; failure = null; } + break; + } + } catch (e) { log("sink error:", e.message); } + }; + + sess.approve = async (callId, name, args) => { + const t = tools.get(callId); + const asked = callClient("session/request_permission", { + sessionId: sid, + toolCall: { + toolCallId: t ? t.acpId : callId, + title: toolTitle(name, args), + kind: TOOL_KIND[name] || "other", + status: "pending", + rawInput: args, + ...(t && t.diff ? { content: t.diff } : {}), + }, + options: [ + { optionId: "allow_once", name: "Allow", kind: "allow_once" }, + { optionId: "allow_always", name: "Allow, don't ask again", kind: "allow_always" }, + { optionId: "reject_once", name: "Reject", kind: "reject_once" }, + ], + }); + // A well-behaved client answers `cancelled` on session/cancel, but a turn + // parked here isn't inside the agent loop — so keep a local escape hatch + // rather than depending on the client to unblock us. + let deny; + const settled = new Promise((resolve) => { + deny = () => resolve(null); + asked.then(resolve, () => resolve(null)); + }); + sess.pendingPermissions.add(deny); + let res; + try { res = await settled; } + finally { sess.pendingPermissions.delete(deny); } + if (!res) return false; + + const outcome = res.outcome; + if (!outcome || outcome.outcome !== "selected") return false; + if (outcome.optionId === "allow_always") { + // "don't ask again" only makes sense as a real mode change — otherwise the + // next call prompts again and the client looks broken. + sess.mode = "auto"; + sess.agent.approvalMode = "auto"; + up({ sessionUpdate: "current_mode_update", currentModeId: "auto" }); + return true; + } + return outcome.optionId === "allow_once"; + }; + + try { + await sess.agent.send(text, images); + } finally { + sess.sink = () => {}; + sess.approve = async () => false; + persist(sess); + } + + if (failure) throw new RpcError(-32000, failure); + return { stopReason }; +} + +// ── method handlers ─────────────────────────────────────────────── +const handlers = { + async initialize(params) { + prewarmGateway(log); // overlap the OmniRoute boot with the client's setup + const wanted = Number(params && params.protocolVersion); + return { + protocolVersion: Number.isFinite(wanted) && wanted < PROTOCOL_VERSION ? wanted : PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: false, embeddedContext: true }, + // OmniWork's tools do their own filesystem and process work, so we never + // call back into the client's fs/* or terminal/*. + mcpCapabilities: { http: false, sse: false }, + }, + agentInfo: { name: "omniwork", title: "OmniWork", version: VERSION }, + authMethods: [], + }; + }, + + // No auth: the gateway is local and keyless. Declared so clients that probe + // the method get a clean result instead of "unknown method". + async authenticate() { return {}; }, + + async "session/new"(params) { + const cwd = params && params.cwd; + if (!cwd || !path.isAbsolute(cwd)) throw new RpcError(-32602, "cwd must be an absolute path"); + if (!fs.existsSync(cwd)) throw new RpcError(-32602, `cwd does not exist: ${cwd}`); + const id = `sess_${Date.now().toString(36)}_${++sessionSeq}`; + const sess = await buildSession({ id, cwd, mode: DEFAULT_MODE }); + persist(sess); + setTimeout(() => pushCommands(sess), 0); // after we've returned the sessionId + return { sessionId: id, modes: { currentModeId: sess.mode, availableModes: MODES } }; + }, + + async "session/load"(params) { + const id = params && params.sessionId; + const cwd = params && params.cwd; + if (!id) throw new RpcError(-32602, "sessionId is required"); + let sess = sessions.get(id); + if (!sess) { + let saved = null; + try { saved = JSON.parse(fs.readFileSync(sessionFile(id), "utf8")); } catch {} + if (!saved) throw new RpcError(-32602, `unknown session: ${id}`); + sess = await buildSession({ id, cwd: cwd || saved.cwd, mode: saved.mode, messages: saved.messages }); + } + // ACP requires the whole conversation to be replayed as updates *before* we + // answer the request. + for (const m of sess.agent.messages) { + if (m.role !== "user" && m.role !== "assistant") continue; + const text = typeof m.content === "string" + ? m.content + : (Array.isArray(m.content) ? m.content.filter((c) => c && c.type === "text").map((c) => c.text).join("\n") : ""); + if (!text.trim()) continue; + notify("session/update", { + sessionId: id, + update: { + sessionUpdate: m.role === "user" ? "user_message_chunk" : "agent_message_chunk", + messageId: `replay${++msgReplaySeq}`, + content: { type: "text", text }, + }, + }); + } + pushCommands(sess); + return { modes: { currentModeId: sess.mode, availableModes: MODES } }; + }, + + async "session/prompt"(params) { + const sess = sessions.get(params && params.sessionId); + if (!sess) throw new RpcError(-32602, `unknown session: ${params && params.sessionId}`); + if (sess.running) throw new RpcError(-32000, "a prompt is already running in this session"); + sess.running = true; + try { return await runTurn(sess, params.prompt); } + finally { sess.running = false; } + }, + + async "session/set_mode"(params) { + const sess = sessions.get(params && params.sessionId); + if (!sess) throw new RpcError(-32602, `unknown session: ${params && params.sessionId}`); + const modeId = params && params.modeId; + if (!MODES.some((m) => m.id === modeId)) throw new RpcError(-32602, `unknown mode: ${modeId}`); + sess.mode = modeId; + sess.agent.approvalMode = modeId; + persist(sess); + notify("session/update", { sessionId: sess.id, update: { sessionUpdate: "current_mode_update", currentModeId: modeId } }); + return {}; + }, +}; + +const notifications = { + "session/cancel"(params) { + const sess = sessions.get(params && params.sessionId); + if (!sess) return; + sess.agent.abort(); + for (const deny of sess.pendingPermissions) { try { deny(); } catch {} } + }, + "initialized"() {}, +}; + +// ── stdio loop ──────────────────────────────────────────────────── +async function handle(msg) { + // A response to something we asked the client. + if (msg.id != null && msg.method === undefined) { + const p = pendingOut.get(msg.id); + if (!p) return; + pendingOut.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message || "client error")); + else p.resolve(msg.result); + return; + } + + const { id, method, params } = msg; + if (id == null) { + const n = notifications[method]; + if (n) { try { n(params || {}); } catch (e) { log("notification error:", e.message); } } + return; + } + + const h = handlers[method]; + if (!h) { write({ jsonrpc: "2.0", id, error: { code: -32601, message: `unknown method: ${method}` } }); return; } + try { + write({ jsonrpc: "2.0", id, result: await h(params || {}) }); + } catch (e) { + write({ jsonrpc: "2.0", id, error: { code: e.code || -32000, message: e.message } }); + } +} + +let buf = ""; +process.stdin.on("data", (chunk) => { + buf += chunk.toString(); + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + handle(msg).catch((e) => log("handler error:", e.message)); + } +}); +process.stdin.on("end", () => process.exit(0)); +log(`OmniWork ACP agent ready (stdio, protocol v${PROTOCOL_VERSION}, mode ${DEFAULT_MODE})`); diff --git a/electron/agent.js b/electron/agent.js index 278b5f8..a9a9ce9 100644 --- a/electron/agent.js +++ b/electron/agent.js @@ -90,7 +90,7 @@ function subLabel(name, args) { } class Agent { - constructor({ baseUrl, apiKey, model, workspace, emit, mcp, memory = null, skillsDir = null, browser = null, canSpawn = true, depth = 0, approvalMode = "auto", approver = null }) { + constructor({ baseUrl, apiKey, model, workspace, emit, mcp, memory = null, skillsDir = null, browser = null, canSpawn = true, depth = 0, approvalMode = "auto", approver = null, streaming = true }) { this.baseUrl = baseUrl.replace(/\/$/, ""); this.apiKey = apiKey; this.model = model || "auto"; @@ -105,6 +105,7 @@ class Agent { this.depth = depth; this.approvalMode = approvalMode; // "auto" | "ask" this.approver = approver; // async (name, args) => boolean + this.streaming = streaming; // false when nobody is watching the deltas this.lastText = ""; this.turnUndo = new Map(); // path -> original content (or null if newly created) this.undoAvailable = false; @@ -234,6 +235,12 @@ class Agent { } async callModel() { + // Headless callers (MCP delegation) throw the deltas away, so the streaming + // attempt buys nothing — and when free `auto` routing lands on a provider + // that returns an empty stream, it costs a whole second request per step. + // One request per step is dramatically faster there. + if (!this.streaming) return await this.#requestModel(false); + // Try streaming for a live feel. Free `auto` routing hits many providers and // some return empty streams — if that happens, fall back to a reliable // non-streaming request. @@ -354,6 +361,10 @@ class Agent { const child = new Agent({ baseUrl: this.baseUrl, apiKey: this.apiKey, model: this.model, workspace: this.workspace, mcp: this.mcp, browser: this.browser, canSpawn: false, depth: this.depth + 1, + // Nobody reads a subagent's token-by-token output — only its tool + // labels and final summary. Skipping the stream attempt halves the + // worst-case request count across the whole fan-out. + streaming: false, emit: (type, payload) => { if (type === "tool_call") this.emit("subagent", { groupId, subId, kind: "tool", tool: subLabel(payload.name, payload.args) }); else if (type === "assistant") this.emit("subagent", { groupId, subId, kind: "text", snippet: String(payload.content || "").slice(0, 160) }); diff --git a/electron/headless.js b/electron/headless.js new file mode 100644 index 0000000..8667414 --- /dev/null +++ b/electron/headless.js @@ -0,0 +1,100 @@ +"use strict"; +// Shared bootstrap for the headless stdio servers (mcp-server.js, acp-server.js). +// +// Both run on plain Node — no Electron, no renderer — and both need the same +// three things: the OmniRoute gateway, the user's app data dir, and the agent +// environment (skills, memory, project registry) so a delegated or remote-driven +// agent gets everything the desktop app has taught OmniWork. + +const os = require("node:os"); +const fs = require("node:fs"); +const path = require("node:path"); +const { Gateway, PORT } = require("./sidecar"); +const { ProjectManager } = require("./projects"); +const { BrowserManager } = require("./browser"); + +const HOST = "127.0.0.1"; + +// Same data dir as the desktop app — headless agents share its installed skills, +// saved memory, and project registry. +function appDataDir() { + const h = os.homedir(); + if (process.platform === "darwin") return path.join(h, "Library", "Application Support", "omniwork"); + if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(h, "AppData", "Roaming"), "omniwork"); + return path.join(process.env.XDG_CONFIG_HOME || path.join(h, ".config"), "omniwork"); +} + +const DATA_DIR = appDataDir(); +const SKILLS_DIR = path.join(DATA_DIR, "skills"); +const GLOBAL_MEMORY_DIR = path.join(DATA_DIR, "memory"); + +const browser = new BrowserManager(); // search + static page fetch (no Electron here) +let projectsMgr = null; +const projects = () => (projectsMgr ||= new ProjectManager(path.join(DATA_DIR, "projects"))); + +function agentEnv(workspace) { + return { + skillsDir: SKILLS_DIR, + browser, + memory: { + globalDir: GLOBAL_MEMORY_DIR, + projectDir: projects().memoryDir(projects().forWorkspace(workspace).id), + }, + }; +} + +// ── gateway ─────────────────────────────────────────────────────── +// A headless server can point its agents at any OpenAI-compatible endpoint. +// Unset (the default) means the bundled OmniRoute gateway on free models. +function externalGateway() { + const baseUrl = process.env.OMNIWORK_BASE_URL; + if (!baseUrl) return null; + return { baseUrl: baseUrl.replace(/\/$/, ""), apiKey: process.env.OMNIWORK_API_KEY || "omniwork" }; +} + +let gatewayInfo = null; +let booting = null; + +// Booting OmniRoute cold takes tens of seconds (Next.js standalone server), and +// that cost used to land on the caller's first request. Kick it off as soon as a +// client connects so it overlaps with the host reading our tool list and +// deciding what to do — by the time real work arrives the gateway is usually up. +function prewarmGateway(log = () => {}) { + if (process.env.OMNIWORK_NO_PREWARM) return; + ensureGateway(log).catch(() => {}); +} + +async function ensureGateway(log = () => {}) { + if (gatewayInfo) return gatewayInfo; + const ext = externalGateway(); + if (ext) { gatewayInfo = ext; log("using external gateway", ext.baseUrl); return gatewayInfo; } + if (booting) return booting; + + booting = (async () => { + // Reuse a gateway already running (e.g. the desktop app) before spawning one. + try { + const res = await fetch(`http://${HOST}:${PORT}/v1/models`, { signal: AbortSignal.timeout(2500) }); + if (res.ok || res.status === 401) { + gatewayInfo = { baseUrl: `http://${HOST}:${PORT}/v1`, apiKey: "omniwork" }; + log("reusing running gateway"); + return gatewayInfo; + } + } catch {} + log("starting bundled gateway…"); + const dataDir = path.join(os.homedir(), ".omniwork"); + fs.mkdirSync(dataDir, { recursive: true }); + const gw = new Gateway({ dataDir, onStatus: (s) => log("gateway", s.state, s.detail || "") }); + await gw.start(); + gatewayInfo = { baseUrl: gw.baseUrl, apiKey: gw.apiKey }; + return gatewayInfo; + })(); + + try { return await booting; } + finally { booting = null; } +} + +module.exports = { + DATA_DIR, SKILLS_DIR, GLOBAL_MEMORY_DIR, + browser, projects, agentEnv, + ensureGateway, prewarmGateway, +}; diff --git a/electron/main.js b/electron/main.js index a98ad7d..fa5b9f1 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,5 +1,5 @@ "use strict"; -const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron"); +const { app, BrowserWindow, ipcMain, dialog, shell, clipboard } = require("electron"); const path = require("node:path"); const os = require("node:os"); const fs = require("node:fs"); @@ -24,7 +24,7 @@ let projects = null; let browser = null; let scheduler = null; -const state = { model: "auto", approval: "auto", lastWorkspace: null, gateway: { state: "boot" } }; +const state = { model: "auto", approval: "auto", copyOnSelect: true, lastWorkspace: null, gateway: { state: "boot" } }; function prefsPath() { return path.join(app.getPath("userData"), "prefs.json"); } function loadPrefs() { @@ -32,7 +32,7 @@ function loadPrefs() { return {}; } function savePrefs() { - try { fs.writeFileSync(prefsPath(), JSON.stringify({ workspace: state.lastWorkspace, model: state.model, approval: state.approval }, null, 2)); } catch {} + try { fs.writeFileSync(prefsPath(), JSON.stringify({ workspace: state.lastWorkspace, model: state.model, approval: state.approval, copyOnSelect: state.copyOnSelect }, null, 2)); } catch {} } function send(channel, payload) { if (win && !win.isDestroyed()) win.webContents.send(channel, payload); } function activeWorkspace() { @@ -73,6 +73,7 @@ async function boot() { else if (prefs.workspace && fs.existsSync(prefs.workspace)) state.lastWorkspace = prefs.workspace; state.model = prefs.model || "auto"; state.approval = ["auto", "ask", "edits", "plan"].includes(prefs.approval) ? prefs.approval : "auto"; + state.copyOnSelect = prefs.copyOnSelect !== false; createWindow(); @@ -112,12 +113,18 @@ async function boot() { ipcMain.handle("app:state", () => ({ model: state.model, approval: state.approval, + copyOnSelect: state.copyOnSelect, gateway: state.gateway, sessions: sessions ? sessions.list() : [], activeId: sessions ? sessions.activeId : null, mcp: mcp ? mcp.list() : [], })); ipcMain.handle("app:setModel", (_e, model) => { state.model = model; if (sessions) sessions.setModel(model); savePrefs(); return true; }); +// Copy-on-select goes through the main process: the renderer's async clipboard +// API needs focus + a permission grant, and neither holds while a drag-select +// is still in flight. +ipcMain.handle("app:copy", (_e, text) => { const t = String(text || ""); if (t) clipboard.writeText(t); return true; }); +ipcMain.handle("app:setCopyOnSelect", (_e, on) => { state.copyOnSelect = !!on; savePrefs(); return state.copyOnSelect; }); ipcMain.handle("gateway:openDashboard", () => { if (gateway) shell.openExternal(gateway.dashboardUrl()); return true; }); ipcMain.handle("models:list", async () => { if (!gateway) return []; @@ -140,9 +147,9 @@ ipcMain.handle("session:transcript", (_e, id) => { const s = sessions.sessions.get(id); return { events: sessions.transcript(id), pendingApproval: (s && s.pendingApproval) || null }; }); -ipcMain.handle("session:send", async (_e, { id, text, images, label }) => { +ipcMain.handle("session:send", async (_e, { id, text, images, label, pastes }) => { if (!sessions) { send("session:event", { sessionId: id, type: "error", message: "Engine still starting." }); return false; } - await sessions.send(id, text, images, label); + await sessions.send(id, text, images, label, pastes); return true; }); ipcMain.handle("session:stop", (_e, id) => { if (sessions) sessions.stop(id); return true; }); diff --git a/electron/mcp-server.js b/electron/mcp-server.js index f47ded3..229ee16 100644 --- a/electron/mcp-server.js +++ b/electron/mcp-server.js @@ -13,84 +13,52 @@ // Speaks JSON-RPC 2.0 over stdio (newline-delimited). stdout is reserved for the // protocol; all logging goes to stderr. -const path = require("node:path"); -const os = require("node:os"); const fs = require("node:fs"); const { ensureShellPath } = require("./shell-path"); -const { Gateway } = require("./sidecar"); const { Agent } = require("./agent"); -const { ProjectManager } = require("./projects"); -const { BrowserManager } = require("./browser"); +const { agentEnv, browser, ensureGateway, prewarmGateway, SKILLS_DIR } = require("./headless"); const skillsApi = require("./skills"); ensureShellPath(); // MCP clients can launch us with a minimal environment too const log = (...a) => process.stderr.write("[omniwork-mcp] " + a.join(" ") + "\n"); -const PORT = Number(process.env.OMNIWORK_GATEWAY_PORT || 20128); -const HOST = "127.0.0.1"; -// Same data dir as the desktop app: delegated agents share its installed -// skills, saved memory, and project registry, so delegation gets everything -// the user has taught OmniWork. -function appDataDir() { - const h = os.homedir(); - if (process.platform === "darwin") return path.join(h, "Library", "Application Support", "omniwork"); - if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(h, "AppData", "Roaming"), "omniwork"); - return path.join(process.env.XDG_CONFIG_HOME || path.join(h, ".config"), "omniwork"); -} -const DATA_DIR = appDataDir(); -const SKILLS_DIR = path.join(DATA_DIR, "skills"); -const GLOBAL_MEMORY_DIR = path.join(DATA_DIR, "memory"); -const browser = new BrowserManager(); // search + static page fetch (no Electron here) -let projectsMgr = null; -const projects = () => (projectsMgr ||= new ProjectManager(path.join(DATA_DIR, "projects"))); - -function agentEnv(workspace) { - return { - skillsDir: SKILLS_DIR, - browser, - memory: { globalDir: GLOBAL_MEMORY_DIR, projectDir: projects().memoryDir(projects().forWorkspace(workspace).id) }, - }; -} - -let gatewayInfo = null; // { baseUrl, apiKey } - -// Reuse a gateway already running (e.g. the desktop app); else boot our own. -async function ensureGateway() { - if (gatewayInfo) return gatewayInfo; - try { - const res = await fetch(`http://${HOST}:${PORT}/v1/models`, { signal: AbortSignal.timeout(2500) }); - if (res.ok) { gatewayInfo = { baseUrl: `http://${HOST}:${PORT}/v1`, apiKey: "omniwork" }; log("reusing running gateway"); return gatewayInfo; } - } catch {} - log("starting bundled gateway…"); - const dataDir = path.join(os.homedir(), ".omniwork"); - fs.mkdirSync(dataDir, { recursive: true }); - const gw = new Gateway({ dataDir, onStatus: (s) => log("gateway", s.state, s.detail || "") }); - await gw.start(); - gatewayInfo = { baseUrl: gw.baseUrl, apiKey: gw.apiKey }; - return gatewayInfo; -} +// A backstop, not a work budget: a free model that stalls mid-turn used to hang +// the caller until *its* client timeout, with nothing to show for it. On expiry +// we abort the agent and return whatever it managed to finish. +const DELEGATE_TIMEOUT_MS = Number(process.env.OMNIWORK_DELEGATE_TIMEOUT_MS || 600_000); // Run one delegated task; capture a change log + final summary. -async function runDelegate({ task, cwd, parallel }) { - const gw = await ensureGateway(); +async function runDelegate({ task, cwd, progress }) { + const gw = await ensureGateway(log); const workspace = cwd && fs.existsSync(cwd) ? cwd : process.cwd(); const changes = []; + let steps = 0; const agent = new Agent({ baseUrl: gw.baseUrl, apiKey: gw.apiKey, model: process.env.OMNIWORK_MODEL || "auto", workspace, canSpawn: true, ...agentEnv(workspace), + // The caller sees a single tool result, never the token stream. + streaming: false, emit: (type, p) => { - if (type === "tool_call") { + if (type === "thinking") progress(++steps, "thinking…"); + else if (type === "tool_call") { if (p.name === "write_file") changes.push(`wrote ${p.args.path}`); else if (p.name === "edit_file") changes.push(`edited ${p.args.path}`); else if (p.name === "run_command") changes.push(`ran: ${String(p.args.command).slice(0, 80)}`); + progress(steps, `${p.name} ${String(p.args.path || p.args.command || p.args.query || "").slice(0, 60)}`.trim()); } }, }); - await agent.send(task); + + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; agent.abort(); }, DELEGATE_TIMEOUT_MS); + try { await agent.send(task); } + finally { clearTimeout(timer); } + const summary = agent.lastText || "(no summary)"; const changeLog = changes.length ? `\n\nChanges:\n- ${changes.join("\n- ")}` : ""; - return `${summary}${changeLog}`; + const note = timedOut ? `\n\n[stopped after ${Math.round(DELEGATE_TIMEOUT_MS / 1000)}s — this is partial work]` : ""; + return `${summary}${changeLog}${note}`; } const TOOLS = [ @@ -146,13 +114,22 @@ const TOOLS = [ }, ]; -async function callTool(name, args) { - if (name === "delegate") return await runDelegate({ task: args.task, cwd: args.cwd }); +async function callTool(name, args, progress = () => {}) { + if (name === "delegate") return await runDelegate({ task: args.task, cwd: args.cwd, progress }); if (name === "delegate_parallel") { - const gw = await ensureGateway(); + const gw = await ensureGateway(log); const workspace = args.cwd && fs.existsSync(args.cwd) ? args.cwd : process.cwd(); - const agent = new Agent({ baseUrl: gw.baseUrl, apiKey: gw.apiKey, model: process.env.OMNIWORK_MODEL || "auto", workspace, canSpawn: true, ...agentEnv(workspace), emit: () => {} }); + let done = 0; const tasks = (args.tasks || []).map((t, i) => ({ title: `task ${i + 1}`, prompt: t })); + const agent = new Agent({ + baseUrl: gw.baseUrl, apiKey: gw.apiKey, model: process.env.OMNIWORK_MODEL || "auto", + workspace, canSpawn: true, ...agentEnv(workspace), streaming: false, + emit: (type, p) => { + if (type !== "subagent") return; + if (p.kind === "done") progress(++done, `${done}/${tasks.length} subagents finished`, tasks.length); + else if (p.kind === "tool") progress(done, `${p.title || "subagent"}: ${p.tool}`, tasks.length); + }, + }); return await agent.runSubagents(tasks); } if (name === "web_search") return await browser.search(args.query); @@ -180,14 +157,26 @@ function replyErr(id, message) { sendMsg({ jsonrpc: "2.0", id, error: { code: -3 async function handle(msg) { const { id, method, params } = msg; if (method === "initialize") { - reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: { listChanged: false } }, serverInfo: { name: "omniwork", version: "0.5.0" } }); + // Boot OmniRoute now, in the background. It used to start on the first + // delegate call, so the caller paid the whole cold-start (tens of seconds, + // or an engine download on lite builds) before any work began. Starting here + // overlaps it with the host reading our tool list and deciding what to do. + prewarmGateway(log); + reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: { listChanged: false } }, serverInfo: { name: "omniwork", version: require("../package.json").version } }); } else if (method === "notifications/initialized") { // no-op } else if (method === "tools/list") { reply(id, { tools: TOOLS }); } else if (method === "tools/call") { + // Delegation is long-running; without progress the host just sees a silent + // block and no way to tell "working" from "hung". + const token = params && params._meta && params._meta.progressToken; + const progress = (n, message, total) => { + if (token === undefined || token === null) return; + sendMsg({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: n, ...(total ? { total } : {}), message } }); + }; try { - const text = await callTool(params.name, params.arguments || {}); + const text = await callTool(params.name, params.arguments || {}, progress); reply(id, { content: [{ type: "text", text }] }); } catch (e) { reply(id, { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }); diff --git a/electron/preload.js b/electron/preload.js index 21062fa..f3171c1 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -9,7 +9,7 @@ contextBridge.exposeInMainWorld("omniwork", { listSessions: () => ipcRenderer.invoke("session:list"), setActiveSession: (id) => ipcRenderer.invoke("session:setActive", id), getTranscript: (id) => ipcRenderer.invoke("session:transcript", id), - sendMessage: (id, text, images, label) => ipcRenderer.invoke("session:send", { id, text, images, label }), + sendMessage: (id, text, images, label, pastes) => ipcRenderer.invoke("session:send", { id, text, images, label, pastes }), stopSession: (id) => ipcRenderer.invoke("session:stop", id), removeSession: (id) => ipcRenderer.invoke("session:remove", id), pickWorkspace: (id) => ipcRenderer.invoke("session:pickWorkspace", id), @@ -52,6 +52,8 @@ contextBridge.exposeInMainWorld("omniwork", { listModels: () => ipcRenderer.invoke("models:list"), setModel: (model) => ipcRenderer.invoke("app:setModel", model), openDashboard: () => ipcRenderer.invoke("gateway:openDashboard"), + copyText: (text) => ipcRenderer.invoke("app:copy", text), + setCopyOnSelect: (on) => ipcRenderer.invoke("app:setCopyOnSelect", on), on: (channel, cb) => { const allowed = new Set(["session:event", "sessions:list", "gateway:status", "mcp:list", "skills:list"]); diff --git a/electron/sessions.js b/electron/sessions.js index 6ee619d..6622d4e 100644 --- a/electron/sessions.js +++ b/electron/sessions.js @@ -239,14 +239,18 @@ class SessionManager { // `label` is what the transcript shows (e.g. prompt + attachment names); // `text` is the full model input, which may embed attached file contents. - async send(id, text, images, label) { + // `pastes` are the bodies behind any "[Pasted text #n]" tokens in the label — + // carried on the event so the transcript can expand them, now and on restore. + async send(id, text, images, label, pastes) { const sess = this.sessions.get(id); if (!sess) return; sess.status = "running"; this.#pushList(); const shown = (label || text) + (images && images.length ? ` 📎 ${images.length} image${images.length > 1 ? "s" : ""}` : ""); - sess.transcript.push({ type: "user", content: shown }); - this.emit(id, "user", { content: shown }); + const ev = { type: "user", content: shown }; + if (pastes && pastes.length) ev.pastes = pastes; + sess.transcript.push(ev); + this.emit(id, "user", { content: shown, pastes: ev.pastes }); try { await sess.agent.send(text, images); } catch (e) { diff --git a/electron/sidecar.js b/electron/sidecar.js index abd444c..4b5ca50 100644 --- a/electron/sidecar.js +++ b/electron/sidecar.js @@ -10,10 +10,48 @@ // In dev we fall back to the system `node` (or $OMNIWORK_NODE). const { spawn } = require("node:child_process"); +const net = require("node:net"); const path = require("node:path"); const fs = require("node:fs"); const crypto = require("node:crypto"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Is a healthy gateway answering on `port`? Always bounded: a process that +// accepts the connection and then never replies must not park us forever. +// Exported for tests. +async function probeGateway(port, timeoutMs = 2000, host = HOST) { + try { + const res = await fetch(`http://${host}:${port}/v1/models`, { signal: AbortSignal.timeout(timeoutMs) }); + return res.ok || res.status === 401; + } catch { return false; } +} + +// Could we actually bind this port, or is something else holding it? Asking the +// OS directly is the only way left to tell: since the child's output goes to +// /dev/null (so a detached gateway can outlive us without taking EPIPE), we +// never get to read its EADDRINUSE on the way out. +function portAvailable(port, host = HOST) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.once("error", () => resolve(false)); + srv.listen(port, host, () => srv.close(() => resolve(true))); + }); +} + +// Ask the OS for a port nobody is using. Only needed when our well-known port is +// held by something we can't use and mustn't kill. +function freePort() { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once("error", reject); + srv.listen(0, HOST, () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} + // The Node binary used to run the gateway. Packaged builds ship one under // resources/runtime (see scripts/stage-node.js); dev uses system node. function resolveNodeBinary() { @@ -80,9 +118,17 @@ class Gateway { this.proc = null; this.ready = false; this.adopted = false; // true when we reused a gateway we didn't spawn + // Normally the well-known port, so other OmniWork processes can discover and + // adopt us. Only moves if that port turns out to be unusable (see start()). + this.port = PORT; this.baseUrl = BASE_URL; } + #setPort(port) { + this.port = port; + this.baseUrl = `http://${HOST}:${port}/v1`; + } + status(state, detail) { this.onStatus({ state, detail, baseUrl: this.baseUrl }); } @@ -91,10 +137,19 @@ class Gateway { // run left the sidecar behind, or when the MCP server booted one first. Adopting // it beats failing on EADDRINUSE or racing a second copy onto the same DB. async #adoptRunning() { - try { - const res = await fetch(`http://${HOST}:${PORT}/v1/models`, { signal: AbortSignal.timeout(2000) }); - return res.ok || res.status === 401; - } catch { return false; } + return probeGateway(this.port, 2000); + } + + // Something already holds the port. Give it a chance to be a gateway that was + // simply slower to come up than the 2s startup probe allowed. + async #probePatiently(port, budgetMs) { + const until = Date.now() + budgetMs; + while (Date.now() < until) { + if (this._stopped) return false; + if (await probeGateway(port, 3000)) return true; + await sleep(1000); + } + return false; } async start() { @@ -136,10 +191,32 @@ class Gateway { const gwDataDir = path.join(this.dataDir, "omniroute"); fs.mkdirSync(gwDataDir, { recursive: true }); + // Nobody healthy answered above, so if we also cannot bind the port then + // something is sitting on it without serving — a gateway wedged by an + // earlier hard kill, usually. Spawning into that just dies on EADDRINUSE, + // and every later launch dies the same way until someone finds the process + // by hand. Two outs, in order of preference. + if (!(await portAvailable(this.port))) { + this.status("boot", "Port in use — looking for a healthy gateway…"); + // It may simply have been slower to boot than the 2s startup probe. + if (await this.#probePatiently(this.port, 20_000)) { + this.ready = true; + this.adopted = true; // not ours to kill on shutdown + this.status("ready", "OmniRoute · free models"); + this.#watchdog(); + return this.baseUrl; + } + // Genuinely wedged. Move rather than kill: that process may not belong to + // OmniWork, and taking a port by force isn't a decision to make for the user. + const alt = await freePort(); + this.#setPort(alt); + this.status("boot", `Port ${PORT} is blocked — starting on ${alt}…`); + } + const env = { ...process.env, NODE_ENV: "production", - PORT: String(PORT), + PORT: String(this.port), // may differ from PORT if the well-known one was blocked HOSTNAME: HOST, // bind loopback only DATA_DIR: gwDataDir, // OmniRoute reads DATA_DIR for its storage + .env // Let the free "auto" pool fall back to the full pool if a sub-combo is empty. @@ -181,6 +258,7 @@ class Gateway { } #spawnServer(serverEntry, omniDir, env) { + this._exited = false; // Never pipe to us: the gateway is meant to outlive this process (see // #adoptRunning), and a detached child writing to a pipe whose reader has // exited takes EPIPE and dies. /dev/null by default; in DEV, append to a @@ -208,9 +286,10 @@ class Gateway { this.proc.on("exit", (code) => { this.ready = false; + this._exited = true; // lets #waitHealthy give up now instead of at the deadline if (code && code !== 0) this.status("error", `Gateway exited (code ${code})`); }); - this.proc.on("error", (e) => this.status("error", e.message)); + this.proc.on("error", (e) => { this._exited = true; this.status("error", e.message); }); } // A gateway can vanish after we're "ready": an adopted one dies with its @@ -234,11 +313,18 @@ class Gateway { async #waitHealthy(timeoutMs = 90000) { const started = Date.now(); - const url = `http://${HOST}:${PORT}/v1/models`; + const url = `http://${HOST}:${this.port}/v1/models`; let sick = 0; // listening but 5xx — broken storage, not "still booting" while (Date.now() - started < timeoutMs) { + // Our child is already gone. Polling the port for another 80s would only + // be asking whoever else holds it how they are getting on. + if (this._exited) throw new Error("gateway exited during startup"); try { - const res = await fetch(url); + // Without a per-request timeout, a process that accepts the connection + // and then never answers parks this fetch for undici's 300s default — + // and the loop's own deadline is only checked between iterations, so it + // never gets to fire. + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); if (res.ok || res.status === 401) { this.ready = true; this.status("ready", "OmniRoute · free models"); @@ -248,14 +334,14 @@ class Gateway { } catch { sick = 0; // not up yet } - await new Promise((r) => setTimeout(r, 700)); + await sleep(700); } this.status("error", "Gateway did not start in time"); throw new Error("gateway health timeout"); } dashboardUrl() { - return `http://${HOST}:${PORT}/`; + return `http://${HOST}:${this.port}/`; } stop() { @@ -289,4 +375,4 @@ class Gateway { } } -module.exports = { Gateway, PORT, BASE_URL }; +module.exports = { Gateway, PORT, BASE_URL, probeGateway, portAvailable, freePort }; diff --git a/package.json b/package.json index 8084b2f..c03b399 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,15 @@ "test:mcp": "node test/mcp.js", "test:scheduler": "node test/scheduler.js", "test:approvals": "node test/approvals.js", - "mcp": "node electron/mcp-server.js" + "test:selection": "node test/selection.js", + "test:acp": "node test/acp.js", + "test:sidecar": "node test/sidecar.js", + "mcp": "node electron/mcp-server.js", + "acp": "node electron/acp-server.js" }, "bin": { - "omniwork-mcp": "electron/mcp-server.js" + "omniwork-mcp": "electron/mcp-server.js", + "omniwork-acp": "electron/acp-server.js" }, "dependencies": { "omniroute": "^3.8.48", diff --git a/renderer/app.js b/renderer/app.js index 8ab948b..3060148 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -14,6 +14,7 @@ const stub = { listDir: async () => ({ root: null, entries: [] }), readFile: async () => ({ content: "" }), getState: async () => ({ model: "auto", sessions: [], activeId: null, mcp: [] }), setModel: async () => {}, openDashboard: async () => {}, on: () => () => {}, + copyText: async () => {}, setCopyOnSelect: async () => {}, }; const api = window.omniwork || stub; // macOS uses a hidden-inset titlebar, so the rail needs room for the traffic lights. @@ -21,6 +22,7 @@ document.documentElement.dataset.platform = api.platform || "web"; const $ = (id) => document.getElementById(id); const scroll = $("scroll"); const input = $("input"); +const hl = $("input-hl"); const stopBtn = $("stop"); let activeId = null; @@ -34,6 +36,8 @@ let currentMcp = []; const mentions = new Set(); const pastedImages = []; // data URLs const attachedFiles = []; // { name, content } — text files attached as context +const pastes = new Map(); // n -> { text, lines } — bodies behind [Pasted text #n] tokens +let pasteSeq = 0; const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); const scrollDown = () => (scroll.scrollTop = scroll.scrollHeight); @@ -72,7 +76,36 @@ function welcome() { $("w-cwd")?.addEventListener("click", () => api.pickWorkspace(activeId)); } -function addUser(text) { stopThinking(); endStream(); const e = document.createElement("div"); e.className = "blk user"; e.innerHTML = `>${esc(text)}`; scroll.appendChild(e); scrollDown(); } +// Quoted lines stay dim and paste tokens stay collapsed in the transcript, so a +// 300-line paste reads as one chip instead of burying the conversation. +function userHtml(text) { + return String(text).split("\n").map((line) => + /^>/.test(line) ? `${esc(line)}` + : esc(line).replace(/\[Pasted text #\d+ \+\d+ lines?\]/g, (m) => `${m}`) + ).join("\n"); +} +// The prompt line keeps the compact token; the body opens underneath it, capped +// and scrollable like a tool card, so nothing you sent is hidden from you. +function addUser(text, pastes) { + stopThinking(); endStream(); + const e = document.createElement("div"); + e.className = "blk user"; + e.innerHTML = `>${userHtml(text)}`; + for (const p of pastes || []) { + if (!text.includes(`[Pasted text #${p.n} +`)) continue; // token was edited out before sending + const b = document.createElement("div"); + b.className = "pasted"; + b.innerHTML = + `
⎿Pasted text #${p.n}` + + `· ${p.lines} line${p.lines === 1 ? "" : "s"}hide
` + + `
${esc(p.text)}
`; + b.querySelector(".pasted-head").addEventListener("click", () => { + b.querySelector(".pasted-toggle").textContent = b.classList.toggle("collapsed") ? "show" : "hide"; + }); + e.appendChild(b); + } + scroll.appendChild(e); scrollDown(); +} function addAssistant(text) { stopThinking(); const e = document.createElement("div"); e.className = "blk assistant"; e.innerHTML = md(text); scroll.appendChild(e); scrollDown(); } function addError(msg) { stopThinking(); endStream(); const e = document.createElement("div"); e.className = "errline"; e.textContent = "✗ " + msg; scroll.appendChild(e); scrollDown(); } function addSystem(text) { const e = document.createElement("div"); e.className = "sysline"; e.textContent = text; scroll.appendChild(e); scrollDown(); } @@ -184,7 +217,7 @@ function stopThinking() { if (thinkTimer) { clearInterval(thinkTimer); thinkTime function renderEvent(ev, live) { switch (ev.type) { - case "user": addUser(ev.content); if (live) { turnStart = Date.now(); turnStats = null; } break; + case "user": addUser(ev.content, ev.pastes); if (live) { turnStart = Date.now(); turnStats = null; } break; case "assistant_delta": if (live) streamDelta(ev.chunk); break; case "assistant": if (!endStream(ev.content)) addAssistant(ev.content); break; case "system": addSystem(ev.content); break; @@ -445,20 +478,212 @@ window.addEventListener("dragover", (e) => e.preventDefault()); window.addEventListener("dragenter", (e) => { e.preventDefault(); if (++dragDepth === 1 && e.dataTransfer?.types?.includes("Files")) document.body.classList.add("dropping"); }); window.addEventListener("dragleave", () => { if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove("dropping"); } }); window.addEventListener("drop", (e) => { e.preventDefault(); dragDepth = 0; document.body.classList.remove("dropping"); handleFiles(e.dataTransfer.files); }); -// paste an image into the composer +// paste into the composer: images become chips, long text collapses to a token input.addEventListener("paste", (e) => { const items = (e.clipboardData && e.clipboardData.items) || []; + let sawImage = false; for (const it of items) { if (it.type && it.type.startsWith("image/")) { const file = it.getAsFile(); if (!file) continue; + sawImage = true; const reader = new FileReader(); reader.onload = () => { pastedImages.push(reader.result); renderChips(); }; reader.readAsDataURL(file); e.preventDefault(); } } + if (sawImage) return; + const text = e.clipboardData && e.clipboardData.getData("text/plain"); + if (text && isLong(text)) { e.preventDefault(); insertPaste(text); } +}); +// ── composer text: quoted lines + collapsed pastes ───────────────── +// Short text lands in the prompt as `> ` quoted lines you can see and edit. +// Anything long collapses to a "[Pasted text #1 +322 lines]" token, exactly as +// Claude Code does — the body is held aside and spliced back in on send. +const PASTE_MAX_LINES = 6, PASTE_MAX_CHARS = 800; +const PASTE_TOKEN = /\[Pasted text #(\d+) \+\d+ lines?\]/g; +const lineCount = (t) => t.split("\n").length; +const isLong = (t) => lineCount(t) > PASTE_MAX_LINES || t.length > PASTE_MAX_CHARS; +const tidy = (t) => String(t).replace(/\r\n?/g, "\n").replace(/\s+$/, ""); + +// Quoted lines and paste tokens are the only things the mirror styles; the rest +// is plain text and must land at exactly the same coordinates as the textarea's. +function syncHighlight() { + // Every line is wrapped and the newlines only join them: a bare leading + // newline would be swallowed by
 parsing and shift the mirror one line
+  // off the textarea, and a trailing one would add a phantom line.
+  hl.innerHTML = input.value.split("\n").map((line) =>
+    /^>/.test(line)
+      ? `${esc(line)}`
+      : `${esc(line).replace(PASTE_TOKEN, (m) => `${m}`)}`
+  ).join("\n");
+  hl.scrollTop = input.scrollTop;
+}
+input.addEventListener("scroll", () => { hl.scrollTop = input.scrollTop; });
+
+function insertAtCaret(text) {
+  const a = input.selectionStart, b = input.selectionEnd, v = input.value;
+  input.value = v.slice(0, a) + text + v.slice(b);
+  const pos = a + text.length;
+  input.setSelectionRange(pos, pos);
+  grow(); input.focus();
+}
+
+function insertPaste(text) {
+  const t = tidy(text);
+  if (!t) return;
+  const n = ++pasteSeq, lines = lineCount(t);
+  pastes.set(n, { text: t, lines });
+  const before = input.value.slice(0, input.selectionStart);
+  const pad = before && !/\s$/.test(before) ? " " : "";
+  insertAtCaret(`${pad}[Pasted text #${n} +${lines} line${lines === 1 ? "" : "s"}] `);
+}
+
+// Appended rather than inserted at the caret: you quote first, then type at it.
+function insertQuote(text) {
+  const t = tidy(text);
+  if (!t) return;
+  if (isLong(t)) { insertPaste(t); return; }
+  const v = input.value;
+  input.value = v + (v && !v.endsWith("\n") ? "\n" : "") + t.split("\n").map((l) => "> " + l).join("\n") + "\n";
+  input.setSelectionRange(input.value.length, input.value.length);
+  grow(); input.focus();
+}
+
+// A paste token is one thing, not 27 characters. Backspace/Delete touching it
+// removes the whole block in a single keystroke rather than nibbling it into
+// unmatchable debris. The trailing space we inserted goes with it, so the
+// keypress right after pasting undoes the paste.
+function deleteTokenAtCaret(key) {
+  if (input.selectionStart !== input.selectionEnd) return false; // a real selection deletes normally
+  const pos = input.selectionStart, v = input.value;
+  for (const m of v.matchAll(PASTE_TOKEN)) {
+    const start = m.index;
+    const end = start + m[0].length + (v[start + m[0].length] === " " ? 1 : 0);
+    const inside = key === "Backspace" ? pos > start && pos <= end : pos >= start && pos < end;
+    if (!inside) continue;
+    input.value = v.slice(0, start) + v.slice(end);
+    input.setSelectionRange(start, start);
+    pastes.delete(Number(m[1]));
+    grow();
+    return true;
+  }
+  return false;
+}
+
+// Deleting a token from the prompt drops its body — the text is the source of
+// truth, so what gets sent is always what you can see.
+function expandPastes(text) {
+  return text.replace(PASTE_TOKEN, (m, n) => {
+    const p = pastes.get(Number(n));
+    return p ? p.text : m;
+  });
+}
+
+// ── selection: copy-on-select + "add to chat" ──────────────────────
+// Like Claude Code's terminal: highlighting transcript text copies it straight
+// to the clipboard, and a floating pill offers to quote it into the composer.
+let copyOnSelect = true;
+let selBar = null, lastCopied = "", toastTimer = null;
+const MOD = api.platform === "darwin" ? "⌘" : "Ctrl+";
+const EDITABLE = "input, textarea, [contenteditable=true]";
+
+// Only transcript text participates. Selections inside the composer, the
+// project-page fields, or a modal mean the user is editing, not quoting.
+function transcriptSelection() {
+  const sel = window.getSelection();
+  if (!sel || sel.isCollapsed || !sel.rangeCount) return null;
+  const text = sel.toString();
+  if (!text.trim()) return null;
+  if (document.activeElement && document.activeElement.matches(EDITABLE)) return null;
+  const r = sel.getRangeAt(0);
+  const node = r.commonAncestorContainer;
+  const el = node.nodeType === 1 ? node : node.parentElement;
+  if (!el || !scroll.contains(el) || el.closest(EDITABLE)) return null;
+  return { text, rect: r.getBoundingClientRect() };
+}
+
+function flashToast(msg) {
+  let t = $("toast");
+  if (!t) { t = document.createElement("div"); t.id = "toast"; t.className = "toast"; document.body.appendChild(t); }
+  t.textContent = msg;
+  t.classList.add("show");
+  clearTimeout(toastTimer);
+  toastTimer = setTimeout(() => t.classList.remove("show"), 1000);
+}
+
+function hideSelBar() { if (selBar) { selBar.remove(); selBar = null; } }
+function showSelBar(text, rect) {
+  hideSelBar();
+  selBar = document.createElement("div");
+  selBar.className = "selbar";
+  selBar.innerHTML =
+    `` +
+    (copyOnSelect ? "" : ``);
+  // mousedown + preventDefault keeps the highlight from flickering away as the
+  // pill is clicked; the text is captured here anyway, so it can't be lost.
+  selBar.addEventListener("mousedown", (e) => {
+    const b = e.target.closest(".selbar-btn");
+    if (!b) return;
+    e.preventDefault(); e.stopPropagation();
+    if (b.dataset.act === "add") addQuote(text);
+    else { copySelection(text); hideSelBar(); }
+  });
+  document.body.appendChild(selBar);
+  placeSelBar(rect);
+}
+function placeSelBar(rect) {
+  const w = selBar.offsetWidth, h = selBar.offsetHeight;
+  const left = Math.max(8, Math.min(rect.left + rect.width / 2 - w / 2, window.innerWidth - w - 8));
+  const above = rect.top - h - 8;
+  selBar.style.left = left + "px";
+  selBar.style.top = (above >= 8 ? above : Math.min(rect.bottom + 8, window.innerHeight - h - 8)) + "px";
+}
+// The transcript auto-scrolls while the agent streams. Follow the selection
+// instead of dismissing the pill out from under whoever is reaching for it.
+function repositionSelBar() {
+  if (!selBar) return;
+  const s = transcriptSelection();
+  if (!s) { hideSelBar(); return; }
+  const view = scroll.getBoundingClientRect();
+  if (s.rect.bottom < view.top || s.rect.top > view.bottom) { hideSelBar(); return; }
+  placeSelBar(s.rect);
+}
+
+function copySelection(text) { lastCopied = text; api.copyText?.(text); flashToast("✓ copied"); }
+
+function addQuote(text) {
+  insertQuote(text);
+  hideSelBar();
+  window.getSelection()?.removeAllRanges();
+}
+
+function onSelectionSettled() {
+  const s = transcriptSelection();
+  if (!s) { hideSelBar(); lastCopied = ""; return; }
+  if (copyOnSelect && s.text !== lastCopied) copySelection(s.text);
+  showSelBar(s.text, s.rect);
+}
+document.addEventListener("mouseup", () => setTimeout(onSelectionSettled, 0));
+document.addEventListener("keyup", (e) => { if (e.shiftKey || e.key === "Shift") setTimeout(onSelectionSettled, 0); });
+// The pill is anchored to viewport coordinates, so scrolling has to move it.
+scroll.addEventListener("scroll", repositionSelBar, { passive: true });
+window.addEventListener("resize", repositionSelBar);
+document.addEventListener("keydown", (e) => {
+  if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return;
+  if (e.key !== "l" && e.key !== "L") return;
+  const s = transcriptSelection();
+  if (s) { e.preventDefault(); addQuote(s.text); }
 });
+
+// announce:false is the restore-from-prefs path — no need to write them back.
+function setCopyOnSelect(on, { announce = true } = {}) {
+  copyOnSelect = !!on;
+  if (announce) api.setCopyOnSelect?.(copyOnSelect);
+  if (announce) addSystem(copyOnSelect ? "Copy-on-select is on — highlighting text copies it." : "Copy-on-select is off — use the Copy button or ⌘/Ctrl+C.");
+}
+
 async function buildIndex() { if (fileIndex) return fileIndex; const out = []; async function walk(rel, d) { if (d > 4 || out.length > 2000) return; const res = await api.listDir(rel); if (!res.entries) return; for (const e of res.entries) { if (e.type === "file") out.push(e.path); else await walk(e.path, d + 1); } } await walk(".", 0); return (fileIndex = out); }
 // One popup component for @-files, /-commands, and the folder menu.
 // items: [{ label, hint, onPick }]
@@ -492,7 +717,7 @@ async function showFilePop(q) {
   const matches = idx.filter((p) => p.toLowerCase().includes(q.toLowerCase())).slice(0, 20);
   openMenu(matches.map((p) => ({
     label: p.split("/").pop(), hint: p,
-    onPick: () => { mentions.add(p); renderChips(); input.value = input.value.replace(/@[^\s]*$/, "").trimEnd(); input.focus(); },
+    onPick: () => { mentions.add(p); renderChips(); input.value = input.value.replace(/@[^\s]*$/, "").trimEnd(); grow(); input.focus(); },
   })));
 }
 function showCommandPop(q) {
@@ -515,7 +740,13 @@ function applyCommand(c) {
 }
 
 // ── composer ───────────────────────────────────────────────────────
-function grow() { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 200) + "px"; }
+// Every path that changes input.value routes through grow(), so the mirror
+// re-renders here rather than at a dozen call sites.
+function grow() {
+  input.style.height = "auto";
+  input.style.height = Math.min(input.scrollHeight, 200) + "px";
+  syncHighlight();
+}
 input.addEventListener("input", () => {
   grow();
   const sm = input.value.match(/^\/([a-z0-9:_-]*)$/i);
@@ -525,6 +756,7 @@ input.addEventListener("input", () => {
 });
 input.addEventListener("keydown", (e) => {
   if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); cycleApproval(); return; }
+  if ((e.key === "Backspace" || e.key === "Delete") && deleteTokenAtCaret(e.key)) { e.preventDefault(); return; }
   if (pendingApproval && !input.value.trim() && (e.key === "y" || e.key === "n")) { e.preventDefault(); pendingApproval(e.key === "y"); return; }
   if (mpop) { if (e.key === "ArrowDown") { e.preventDefault(); moveSel(1); return; } if (e.key === "ArrowUp") { e.preventDefault(); moveSel(-1); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); pickSel(); return; } if (e.key === "Escape") { e.preventDefault(); hidePop(); return; } }
   if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
@@ -538,9 +770,12 @@ document.addEventListener("keydown", (e) => {
 });
 $("approval-toggle").addEventListener("click", cycleApproval);
 async function send() {
-  const text = input.value.trim();
-  if ((!text && !mentions.size && !pastedImages.length) || !activeId) return;
-  if (text.startsWith("/")) { input.value = ""; grow(); hidePop(); handleSlash(text); return; }
+  // `shown` is what stays in the transcript — paste tokens still collapsed.
+  // `text` is what the model gets, with those bodies spliced back in.
+  const shown = input.value.trim();
+  const text = expandPastes(shown);
+  if ((!shown && !mentions.size && !pastedImages.length && !attachedFiles.length) || !activeId) return;
+  if (shown.startsWith("/")) { input.value = ""; grow(); hidePop(); handleSlash(shown); return; }
   // On a project page, a message starts a new session in that project.
   if (viewingProject) {
     const s = await api.createSession({ workspace: viewingProjectPath }).catch(() => null);
@@ -549,16 +784,20 @@ async function send() {
     await switchTo(s.id);
   }
   let full = text; if (mentions.size) full = `${text}\n\nReferenced files: ${[...mentions].map((p) => "@" + p).join(", ")}`;
+  let label = text !== shown ? shown : null;
   // Attached text files ride along for the model; the transcript shows only a label.
-  let label = null;
   if (attachedFiles.length) {
-    label = text + `  📎 ${attachedFiles.map((f) => f.name).join(", ")}`;
+    label = (label || shown) + `  📎 ${attachedFiles.map((f) => f.name).join(", ")}`;
     full += "\n\n" + attachedFiles.map((f) => `## Attached file: ${f.name}\n\`\`\`\n${f.content}\n\`\`\``).join("\n\n");
   }
+  // Only the tokens still in the prompt travel — an edited-out paste is gone.
+  const usedPastes = [...pastes.entries()]
+    .filter(([n]) => shown.includes(`[Pasted text #${n} +`))
+    .map(([n, p]) => ({ n, text: p.text, lines: p.lines }));
   const images = pastedImages.slice();
   const w = $("welcome"); if (w) w.remove();
-  input.value = ""; mentions.clear(); pastedImages.length = 0; attachedFiles.length = 0; renderChips(); grow(); hidePop();
-  await api.sendMessage(activeId, full, images, label);
+  input.value = ""; mentions.clear(); pastedImages.length = 0; attachedFiles.length = 0; pastes.clear(); pasteSeq = 0; renderChips(); grow(); hidePop();
+  await api.sendMessage(activeId, full, images, label, usedPastes);
 }
 
 // ── slash commands ─────────────────────────────────────────────────
@@ -594,6 +833,7 @@ const COMMANDS = [
     addSystem(r.error ? "✗ " + r.error : (r.installed.length ? `✓ Installed: ${r.installed.map((n) => "/" + n).join("  ")}` : "No SKILL.md directories found there."));
   } },
   { name: "approve", args: "auto|ask|edits|plan", desc: "Set approval mode (Shift+Tab cycles)", run: (arg) => setApproval(arg) },
+  { name: "copy", args: "on|off", desc: "Copy highlighted text automatically", run: (arg) => setCopyOnSelect(arg ? arg.toLowerCase() !== "off" : !copyOnSelect) },
 ];
 function allCommands() {
   const base = [...COMMANDS, ...RECIPES.map((r) => ({ name: "recipe:" + r.slug, desc: r.desc, run: () => runRecipe(r) }))];
@@ -815,6 +1055,7 @@ $("recipes-modal").addEventListener("click", (e) => { if (e.target.id === "recip
   if (st) {
     if (st.model) $("model").value = st.model;
     if (st.approval) setApproval(st.approval, { announce: false });
+    if (st.copyOnSelect !== undefined) setCopyOnSelect(st.copyOnSelect, { announce: false });
     if (st.mcp) renderMcp(st.mcp);
     populateModels(st.model);
     if (st.sessions && st.sessions.length) { renderSessions(st.sessions, st.activeId); await switchTo(st.activeId || st.sessions[0].id); }
diff --git a/renderer/index.html b/renderer/index.html
index bd4b99b..97c9007 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -37,7 +37,12 @@
           
> - + +
+ + +
diff --git a/renderer/styles.css b/renderer/styles.css index 4fbe1d5..60f7067 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -97,6 +97,15 @@ b { font-weight: 600; color: var(--fg); } .blk { max-width: 900px; margin: 0 auto; padding: 3px 0; font-family: var(--mono); } .blk.user .caret { color: var(--accent); font-weight: 700; margin-right: 8px; } .blk.user .utext { white-space: pre-wrap; word-break: break-word; } +.blk.user .uquote { color: var(--dim); } +.blk.user .upaste { color: var(--accent-soft); } +.pasted { margin: 3px 0 2px 17px; } +.pasted-head { display: flex; align-items: center; gap: 6px; color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; } +.pasted-head:hover { color: var(--fg); } +.pasted-head .tool-elbow { color: var(--dimmer); } +.pasted-toggle { color: var(--dimmer); margin-left: 6px; } +.pasted-body { margin: 2px 0 0 17px; padding-left: 9px; border-left: 1px solid var(--border-soft); color: var(--dim); font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow: auto; } +.pasted.collapsed .pasted-body { display: none; } .blk.assistant { white-space: pre-wrap; word-break: break-word; padding: 4px 0 6px; } .blk.assistant code { background: var(--bg-soft); padding: 1px 5px; border-radius: 4px; color: var(--accent-soft); } .blk.assistant pre { background: var(--bg-soft); border: 1px solid var(--border-soft); border-radius: 7px; padding: 11px 13px; overflow-x: auto; margin: 7px 0; } @@ -163,8 +172,18 @@ b { font-weight: 600; color: var(--fg); } .inputbox { max-width: 900px; margin: 0 auto; display: flex; align-items: flex-start; gap: 9px; border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; background: var(--bg-soft); font-family: var(--mono); } .inputbox:focus-within { border-color: var(--accent); } .prompt-caret { color: var(--accent); font-weight: 700; padding-top: 1px; } -#input { flex: 1; background: none; border: none; outline: none; resize: none; color: var(--fg); font-family: var(--mono); font-size: 13px; line-height: 1.55; max-height: 200px; padding: 1px 0; } +/* The textarea and its highlight mirror must lay text out identically — same + font, metrics, padding and wrapping — or the two drift apart as you type. */ +.inputwrap { flex: 1; min-width: 0; position: relative; } +.input-hl, #input { font-family: var(--mono); font-size: 13px; line-height: 1.55; padding: 1px 0; white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word; } +.input-hl { position: absolute; inset: 0; margin: 0; overflow: hidden; pointer-events: none; color: var(--fg); } +.input-hl .q { color: var(--dim); } +.input-hl .pt { color: var(--accent-soft); } +/* Transparent text, visible caret: the mirror underneath does the painting. + --sel is translucent, so the textarea's selection tints it rather than hiding it. */ +#input { display: block; width: 100%; position: relative; background: none; border: none; outline: none; resize: none; color: transparent; caret-color: var(--fg); max-height: 200px; } #input::placeholder { color: var(--dimmer); } +#input::selection { background: var(--sel); } .stopbtn { font-family: var(--mono); font-size: 11.5px; color: var(--red); background: none; border: 1px solid var(--red); border-radius: 5px; padding: 2px 7px; cursor: pointer; flex-shrink: 0; } .attachbtn { background: none; border: none; cursor: pointer; font-size: 14px; padding: 0 2px; flex-shrink: 0; opacity: .55; } .attachbtn:hover { opacity: 1; } @@ -214,6 +233,14 @@ body.dropping::after { content: "Drop files to attach"; position: fixed; inset: .pv-sched-add select { background: var(--bg-soft); border: 1px solid var(--border-soft); border-radius: 6px; color: var(--dim); font-family: inherit; font-size: 12px; padding: 4px 6px; } /* ── @ autocomplete ────────────────────────────────────────────────── */ +/* ── selection: floating "add to chat" pill + copy toast ───────────── */ +.selbar { position: fixed; z-index: 90; display: flex; gap: 2px; background: var(--bg-soft); border: 1px solid var(--border); border-radius: 7px; box-shadow: 0 8px 24px rgba(0,0,0,.5); padding: 3px; } +.selbar-btn { display: inline-flex; align-items: center; gap: 6px; font-family: var(--sans); font-size: 11.5px; color: var(--fg); background: none; border: none; border-radius: 5px; padding: 4px 8px; cursor: pointer; white-space: nowrap; } +.selbar-btn:hover { background: #2f2c27; color: var(--accent); } +.selbar-btn .kbd { color: var(--dimmer); font-size: 10.5px; padding: 0 4px; } +.toast { position: fixed; left: 50%; bottom: 88px; transform: translate(-50%, 6px); z-index: 210; font-size: 11.5px; color: var(--fg); background: var(--bg-soft); border: 1px solid var(--border); border-radius: 6px; padding: 4px 11px; box-shadow: 0 6px 20px rgba(0,0,0,.45); opacity: 0; pointer-events: none; transition: opacity .12s ease, transform .12s ease; } +.toast.show { opacity: 1; transform: translate(-50%, 0); } + .mpop { position: absolute; z-index: 40; background: var(--bg-soft); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 34px rgba(0,0,0,.55); max-height: 260px; overflow: auto; min-width: 280px; padding: 4px; } .mpitem { display: flex; align-items: baseline; gap: 8px; padding: 5px 9px; border-radius: 5px; font-family: var(--mono); font-size: 12.5px; cursor: pointer; } .mpitem.sel, .mpitem:hover { background: var(--sel); } diff --git a/test/acp.js b/test/acp.js new file mode 100644 index 0000000..c9cc1b7 --- /dev/null +++ b/test/acp.js @@ -0,0 +1,108 @@ +// Smoke test: the ACP server speaks Agent Client Protocol v1 over stdio — +// initialize/session/new/set_mode/load/prompt, session updates as notifications, +// and clean JSON-RPC errors for bad input. +// +// OMNIWORK_BASE_URL points the agent at a dead port so no gateway boots: session +// setup still works, and session/prompt fails fast, which is exactly how we +// check that a broken turn surfaces as a JSON-RPC error instead of hanging. +const { spawn } = require("child_process"); +const path = require("path"); + +let fails = 0; +const check = (name, ok) => { console.log((ok ? "✓" : "✗"), name); if (!ok) fails++; }; + +const proc = spawn(process.execPath, [path.join(__dirname, "..", "electron", "acp-server.js")], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, OMNIWORK_NO_PREWARM: "1", OMNIWORK_BASE_URL: "http://127.0.0.1:9/v1" }, +}); + +const pending = new Map(); +const notes = []; // every notification the server pushed at us +let nextId = 1; +let buf = ""; +proc.stdout.on("data", (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); + if (!line) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + if (m.id != null && m.method === undefined) { + if (pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } + } else if (m.method) notes.push(m); + } +}); + +const rpc = (method, params) => new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, resolve); + proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new Error(method + " timed out")); } }, 25_000); +}); +const notify = (method, params) => proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); +const settle = () => new Promise((r) => setTimeout(r, 150)); +const updates = (type) => notes.filter((n) => n.method === "session/update" && n.params.update.sessionUpdate === type); + +(async () => { + const repo = path.join(__dirname, ".."); + + // ── initialize ── + const init = await rpc("initialize", { protocolVersion: 1, clientCapabilities: { fs: {} } }); + check("initialize returns protocol v1", init.result.protocolVersion === 1); + check("initialize identifies the agent", init.result.agentInfo.name === "omniwork"); + check("advertises loadSession", init.result.agentCapabilities.loadSession === true); + check("advertises image prompts", init.result.agentCapabilities.promptCapabilities.image === true); + + // ── session/new ── + const made = await rpc("session/new", { cwd: repo, mcpServers: [] }); + const sid = made.result.sessionId; + check("session/new returns a sessionId", typeof sid === "string" && sid.length > 0); + check("session/new advertises four modes", made.result.modes.availableModes.length === 4); + check("session/new defaults to ask mode", made.result.modes.currentModeId === "ask"); + + const relative = await rpc("session/new", { cwd: "not/absolute", mcpServers: [] }); + check("session/new rejects a relative cwd", relative.error && relative.error.code === -32602); + + await settle(); + check("skills are pushed as available commands", updates("available_commands_update").length === 1); + + // ── modes ── + const mode = await rpc("session/set_mode", { sessionId: sid, modeId: "plan" }); + check("session/set_mode succeeds", !mode.error); + await settle(); + const modeNotes = updates("current_mode_update"); + check("set_mode notifies current_mode_update", modeNotes.length === 1 && modeNotes[0].params.update.currentModeId === "plan"); + + const badMode = await rpc("session/set_mode", { sessionId: sid, modeId: "nonsense" }); + check("session/set_mode rejects an unknown mode", badMode.error && badMode.error.code === -32602); + + // ── load ── + const loaded = await rpc("session/load", { sessionId: sid, cwd: repo, mcpServers: [] }); + check("session/load restores a live session", !loaded.error && loaded.result.modes.currentModeId === "plan"); + const missing = await rpc("session/load", { sessionId: "sess_nope", cwd: repo, mcpServers: [] }); + check("session/load rejects an unknown session", missing.error && missing.error.code === -32602); + + // ── prompt ── + const orphan = await rpc("session/prompt", { sessionId: "sess_nope", prompt: [{ type: "text", text: "hi" }] }); + check("session/prompt rejects an unknown session", orphan.error && orphan.error.code === -32602); + + const empty = await rpc("session/prompt", { sessionId: sid, prompt: [] }); + check("an empty prompt ends the turn cleanly", empty.result && empty.result.stopReason === "end_turn"); + + // Real turn against a dead gateway: must come back as an error, not a hang. + const broken = await rpc("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "say hello" }] }); + check("a failed turn surfaces as a JSON-RPC error", Boolean(broken.error) && /connection|fetch|engine/i.test(broken.error.message)); + + // ── cancel is a notification: must not produce a response or crash ── + notify("session/cancel", { sessionId: sid }); + await settle(); + check("session/cancel is accepted silently", proc.exitCode === null); + + // ── unknown method ── + const unknown = await rpc("session/telepathy", {}); + check("unknown methods return -32601", unknown.error && unknown.error.code === -32601); + + proc.kill(); + console.log(fails ? `\n❌ ${fails} FAILED` : "\n✅ ACP SERVER TEST PASSED"); + process.exit(fails ? 1 : 0); +})().catch((e) => { console.error("crash:", e.message); proc.kill(); process.exit(1); }); diff --git a/test/mcp.js b/test/mcp.js index 7e94147..ba4551a 100644 --- a/test/mcp.js +++ b/test/mcp.js @@ -7,7 +7,12 @@ const path = require("path"); let fails = 0; const check = (name, ok) => { console.log((ok ? "✓" : "✗"), name); if (!ok) fails++; }; -const proc = spawn(process.execPath, [path.join(__dirname, "..", "electron", "mcp-server.js")], { stdio: ["pipe", "pipe", "pipe"] }); +// NO_PREWARM: initialize now boots the gateway in the background, which we don't +// want a unit test spawning a real OmniRoute server for. +const proc = spawn(process.execPath, [path.join(__dirname, "..", "electron", "mcp-server.js")], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, OMNIWORK_NO_PREWARM: "1" }, +}); const pending = new Map(); let nextId = 1; let buf = ""; diff --git a/test/selection.js b/test/selection.js new file mode 100644 index 0000000..b86547d --- /dev/null +++ b/test/selection.js @@ -0,0 +1,275 @@ +// Renderer test: copy-on-select + "add to chat". +// Boots the real app and drives the page through OMNIWORK_UI_TEST, then checks +// the system clipboard actually changed — the whole point of copy-on-select. +const { spawn } = require('node:child_process'); +const fs = require('node:fs'), os = require('node:os'), path = require('node:path'); + +const PROBE = 'SELECTION_PROBE_ONE\nSELECTION_PROBE_TWO'; +const SENTINEL = 'CLIPBOARD_SENTINEL_UNTOUCHED'; + +const page = `(() => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const out = {}; + const scroll = document.getElementById("scroll"); + const input = document.getElementById("input"); + const selectContents = (el) => { + // The engine finishing its boot mid-test re-renders the transcript, which + // detaches our probe nodes — put them back rather than racing the app. + if (!scroll.contains(el)) scroll.appendChild(el); + // A real mousedown in the transcript blurs the composer; synthetic events + // don't move focus, so do it explicitly or the selection reads as "editing". + if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur(); + const sel = window.getSelection(), range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); sel.addRange(range); + }; + return (async () => { + const blk = document.createElement("div"); + blk.className = "blk assistant"; + blk.textContent = ${JSON.stringify(PROBE)}; + scroll.appendChild(blk); + + // 1. highlight transcript text -> clipboard + floating pill + selectContents(blk); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await wait(150); + const bar = document.querySelector(".selbar"); + out.pillShown = !!bar; + out.toastShown = !!document.querySelector("#toast.show"); + if (!bar) return out; + + // 2. "Add to chat" -> visible "> " quoted lines in the prompt itself + bar.querySelector('[data-act="add"]').dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + await wait(60); + out.promptText = input.value; + out.pillDismissed = !document.querySelector(".selbar"); + out.focusBackOnInput = document.activeElement === input; + // the mirror dims those lines rather than the textarea painting them + out.mirrorQuoteLines = document.querySelectorAll("#input-hl .q").length; + out.mirrorText = document.getElementById("input-hl").textContent.trim(); + + // 3. the keyboard shortcut quotes too + selectContents(blk); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "l", metaKey: true, ctrlKey: true, bubbles: true, cancelable: true })); + await wait(60); + out.quoteLinesAfterShortcut = input.value.split("\\n").filter((l) => l.startsWith("> ")).length; + + // 4. a long selection collapses instead of flooding the prompt + input.value = ""; grow(); + const long = document.createElement("div"); + long.className = "blk assistant"; + long.textContent = Array.from({ length: 322 }, (_, i) => "line " + (i + 1)).join("\\n"); + scroll.appendChild(long); + selectContents(long); + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await wait(120); + document.querySelector('.selbar [data-act="add"]').dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + await wait(60); + out.longQuoteCollapsed = input.value.trim(); + out.mirrorPasteTokens = document.querySelectorAll("#input-hl .pt").length; + // the body is held aside and spliced back in on the way to the model + out.expandsBack = expandPastes(input.value).includes("line 322"); + out.expandedLineCount = expandPastes(input.value).split("\\n").filter((l) => /^line \\d+\\s*$/.test(l)).length; + // deleting the token drops its body — what you see is what gets sent + input.value = "just this"; grow(); + out.droppedWhenTokenDeleted = expandPastes(input.value) === "just this"; + long.remove(); + input.value = ""; grow(); + + // 4a2. Backspace touching a token removes the whole block in one keystroke + input.value = ""; grow(); + insertPaste(Array.from({ length: 20 }, (_, i) => "atomic " + i).join("\\n")); + const beforeDel = input.value; + input.setSelectionRange(beforeDel.length, beforeDel.length); // caret past the trailing space + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true })); + await wait(30); + out.backspaceClearedToken = input.value === "" && beforeDel.startsWith("[Pasted text #"); + out.backspaceDroppedBody = expandPastes(beforeDel) === beforeDel; // body no longer known + // ...and it only eats the token, not the words around it + input.value = ""; grow(); + input.value = "before "; input.setSelectionRange(7, 7); grow(); + insertPaste(Array.from({ length: 20 }, (_, i) => "mid " + i).join("\\n")); + const withTail = input.value + "after"; + input.value = withTail; grow(); + const tokenEnd = withTail.indexOf("]") + 2; // token + the space we inserted + input.setSelectionRange(tokenEnd, tokenEnd); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true })); + await wait(30); + out.backspaceKeptSurroundingText = input.value === "before after"; + // plain Backspace elsewhere is untouched + input.value = "plain text"; input.setSelectionRange(10, 10); grow(); + const plainEv = new KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true }); + input.dispatchEvent(plainEv); + out.plainBackspaceUntouched = !plainEv.defaultPrevented && input.value === "plain text"; + input.value = ""; grow(); + + // 4b. a long *paste* into the prompt collapses the same way + input.focus(); + const dt = new DataTransfer(); + dt.setData("text/plain", Array.from({ length: 40 }, (_, i) => "pasted " + i).join("\\n")); + input.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })); + await wait(60); + out.pasteCollapsed = input.value.trim(); + out.pasteExpandsBack = expandPastes(input.value).includes("pasted 39"); + // short pastes are left alone — the browser inserts them normally + input.value = ""; grow(); + const dt2 = new DataTransfer(); + dt2.setData("text/plain", "short one-liner"); + const ev2 = new ClipboardEvent("paste", { clipboardData: dt2, bubbles: true, cancelable: true }); + input.dispatchEvent(ev2); + out.shortPasteUntouched = !ev2.defaultPrevented; + input.value = ""; grow(); + + // 4c. the mirror must wrap and space text exactly like the textarea, or the + // transparent text and the painted text drift apart as you type + const hlEl = document.getElementById("input-hl"); + out.metrics = [ + "one line", + "\\nleading blank line", //
 would eat this newline
+      "trailing blank line\\n",
+      "x".repeat(400),                               // unbreakable run -> hard wrap
+      Array.from({ length: 30 }, () => "word").join(" ") + "\\nsecond\\n> quoted",
+      "[Pasted text #9 +12 lines] and text after",
+    ].map((v) => {
+      input.value = v; grow();
+      return { drift: hlEl.scrollHeight - input.scrollHeight, mirrored: hlEl.textContent === v };
+    });
+    out.metricsAligned = out.metrics.every((m) => Math.abs(m.drift) <= 1 && m.mirrored);
+    input.value = ""; grow();
+
+    // 5. selecting in the composer is editing, not quoting
+    input.value = "editing this text, not quoting it";
+    input.focus(); input.setSelectionRange(0, 12);
+    document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+    await wait(80);
+    out.pillSuppressedInComposer = !document.querySelector(".selbar");
+
+    // 6. ...and so is selecting in a project-page field
+    const ta = document.createElement("textarea");
+    ta.value = "project instructions";
+    scroll.appendChild(ta);
+    ta.focus(); ta.setSelectionRange(0, 7);
+    document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+    await wait(80);
+    out.pillSuppressedInProjectField = !document.querySelector(".selbar");
+    ta.remove();
+
+    // 7. deselecting hides the pill
+    selectContents(blk);
+    document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+    await wait(80);
+    out.pillReshown = !!document.querySelector(".selbar");
+    window.getSelection().removeAllRanges();
+    document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+    await wait(80);
+    out.pillHiddenOnDeselect = !document.querySelector(".selbar");
+
+    // 8. /copy off swaps the implicit copy for an explicit button
+    out.copyCommandFound = !!allCommands().find((c) => c.name === "copy");
+    handleSlash("/copy off");
+    await wait(40);
+    selectContents(blk);
+    document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+    await wait(80);
+    const bar2 = document.querySelector(".selbar");
+    out.copyButtonWhenOff = !!(bar2 && bar2.querySelector('[data-act="copy"]'));
+    window.getSelection().removeAllRanges();
+    document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+    handleSlash("/copy on"); // restore the default so prefs.json isn't left off
+    return out;
+  })();
+})()`;
+
+// Reading the OS clipboard is platform-specific; skip that assertion elsewhere.
+const clip = { darwin: ['pbpaste', []], linux: ['xclip', ['-o', '-selection', 'clipboard']] }[process.platform];
+function readClipboard() {
+  if (!clip) return null;
+  try { return require('node:child_process').execFileSync(clip[0], clip[1], { encoding: 'utf8' }); } catch { return null; }
+}
+function writeClipboard(text) {
+  if (process.platform !== 'darwin') return false;
+  try { require('node:child_process').execSync('pbcopy', { input: text }); return true; } catch { return false; }
+}
+
+// SIGTERM, never SIGKILL: main.js traps it to stop the gateway, which is
+// spawned detached and would otherwise outlive us as an orphan still holding
+// its port — the next launch adopts a dead engine and reports "engine error".
+function shutdown(child) {
+  return new Promise((resolve) => {
+    if (child.exitCode !== null || child.signalCode) return resolve();
+    const hard = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} resolve(); }, 8000);
+    child.once('exit', () => { clearTimeout(hard); resolve(); });
+    try { child.kill('SIGTERM'); } catch { clearTimeout(hard); resolve(); }
+  });
+}
+
+(async () => {
+  const scriptPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ow-sel-')), 'page.js');
+  fs.writeFileSync(scriptPath, page);
+  const clipboardTestable = writeClipboard(SENTINEL);
+
+  // Its own port and profile, so a test run can never collide with — or edit
+  // the prefs of — a copy of the app the developer has open.
+  const PORT = 20429;
+  const profile = path.join(os.tmpdir(), 'omniwork-selection-test');
+  const electron = require.resolve('electron/cli.js');
+  const child = spawn(process.execPath, [electron, path.join(__dirname, '..'), `--user-data-dir=${profile}`], {
+    env: { ...process.env, OMNIWORK_UI_TEST: scriptPath, OMNIWORK_GATEWAY_PORT: String(PORT) },
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+  let buf = '';
+  child.stdout.on('data', (d) => { buf += d; });
+  child.stderr.on('data', (d) => { buf += d; });
+
+  const result = await new Promise((resolve) => {
+    const bail = setTimeout(() => { clearInterval(poll); resolve(null); }, 120_000);
+    const poll = setInterval(() => {
+      const m = buf.match(/\[ui-test\] (.+)/);
+      const err = buf.match(/\[ui-test-error\] (.+)/);
+      if (!m && !err) return;
+      clearInterval(poll); clearTimeout(bail);
+      // Let the renderer's last IPC (the /copy on prefs write) land.
+      setTimeout(() => resolve(m ? JSON.parse(m[1]) : { error: err[1] }), 500);
+    }, 250);
+  });
+  await shutdown(child);
+
+  // Prove we cleaned up after ourselves rather than leaving a spinning orphan.
+  let leaked = false;
+  if (process.platform !== 'win32') {
+    try { require('node:child_process').execFileSync('lsof', ['-nP', `-iTCP:${PORT}`, '-sTCP:LISTEN'], { stdio: 'pipe' }); leaked = true; } catch {}
+  }
+  if (leaked) console.log(`⚠ a gateway is still listening on ${PORT} — orphaned sidecar`);
+
+  if (!result) { console.log('❌ FAILED — timed out waiting for the renderer'); process.exit(1); }
+  console.log(JSON.stringify(result, null, 2));
+
+  const copied = clipboardTestable ? readClipboard() : null;
+  const clipboardOk = !clipboardTestable || copied === PROBE;
+  if (clipboardTestable) console.log('clipboard after highlight:', JSON.stringify(copied));
+  else console.log('clipboard check skipped on this platform');
+
+  const ok =
+    result.pillShown && result.toastShown &&
+    result.promptText === '> SELECTION_PROBE_ONE\n> SELECTION_PROBE_TWO\n' &&
+    result.mirrorQuoteLines === 2 &&
+    result.mirrorText === '> SELECTION_PROBE_ONE\n> SELECTION_PROBE_TWO' &&
+    result.pillDismissed && result.focusBackOnInput &&
+    result.quoteLinesAfterShortcut === 4 &&
+    result.longQuoteCollapsed === '[Pasted text #1 +322 lines]' &&
+    result.mirrorPasteTokens === 1 &&
+    result.expandsBack && result.expandedLineCount === 322 &&
+    result.droppedWhenTokenDeleted &&
+    /^\[Pasted text #\d+ \+40 lines\]$/.test(result.pasteCollapsed) &&
+    result.pasteExpandsBack && result.shortPasteUntouched &&
+    result.metricsAligned &&
+    result.backspaceClearedToken && result.backspaceDroppedBody &&
+    result.backspaceKeptSurroundingText && result.plainBackspaceUntouched &&
+    result.pillSuppressedInComposer && result.pillSuppressedInProjectField &&
+    result.pillReshown && result.pillHiddenOnDeselect &&
+    result.copyCommandFound && result.copyButtonWhenOff &&
+    clipboardOk && !leaked;
+
+  console.log('\n' + (ok ? '✅ SELECTION TEST PASSED (copy-on-select + add to chat)' : '❌ FAILED'));
+  process.exit(ok ? 0 : 1);
+})().catch((e) => { console.error('crash', e); process.exit(1); });
diff --git a/test/sidecar.js b/test/sidecar.js
new file mode 100644
index 0000000..5d5710f
--- /dev/null
+++ b/test/sidecar.js
@@ -0,0 +1,73 @@
+// Gateway port recovery. The regression that motivated this: a process that
+// accepts the connection and then never answers used to park the health probe
+// forever, so a wedged gateway holding port 20128 made every subsequent boot
+// fail with EADDRINUSE and a useless "Gateway exited (code 1)".
+//
+// Fast and deterministic — no OmniRoute here. The full boot path is covered by
+// test/smoke.js.
+const net = require("node:net");
+const http = require("node:http");
+const { probeGateway, portAvailable, freePort } = require("../electron/sidecar");
+
+let fails = 0;
+const check = (name, ok) => { console.log((ok ? "✓" : "✗"), name); if (!ok) fails++; };
+
+const listen = (server) => new Promise((r) => server.listen(0, "127.0.0.1", () => r(server.address().port)));
+
+(async () => {
+  // ── a port with nothing on it ──
+  const dead = await freePort();
+  const t0 = Date.now();
+  check("closed port probes false", (await probeGateway(dead, 2000)) === false);
+  check("…and fails fast, without waiting out the timeout", Date.now() - t0 < 1500);
+
+  // ── wedged: accepts the socket, never replies ──
+  const wedged = net.createServer(() => { /* hold the connection open, say nothing */ });
+  const wedgedPort = await listen(wedged);
+  const t1 = Date.now();
+  const wedgedResult = await probeGateway(wedgedPort, 1000);
+  const wedgedMs = Date.now() - t1;
+  check("wedged listener probes false", wedgedResult === false);
+  check("wedged listener gives up at the timeout, not never", wedgedMs >= 900 && wedgedMs < 4000);
+
+  // ── healthy ──
+  const healthy = http.createServer((req, res) => {
+    if (req.url.startsWith("/v1/models")) { res.writeHead(200, { "content-type": "application/json" }); res.end('{"data":[]}'); }
+    else { res.writeHead(404); res.end(); }
+  });
+  const healthyPort = await listen(healthy);
+  check("healthy gateway probes true", (await probeGateway(healthyPort, 2000)) === true);
+
+  // ── 401: serving, just wants a key. Still a live gateway. ──
+  const guarded = http.createServer((req, res) => { res.writeHead(401); res.end(); });
+  const guardedPort = await listen(guarded);
+  check("401 counts as a live gateway", (await probeGateway(guardedPort, 2000)) === true);
+
+  // ── 500: listening but broken storage — not healthy ──
+  const broken = http.createServer((req, res) => { res.writeHead(500); res.end(); });
+  const brokenPort = await listen(broken);
+  check("500 does not count as healthy", (await probeGateway(brokenPort, 2000)) === false);
+
+  // ── the pre-flight bind check ──
+  // Since 0.11.1 the child's output goes to /dev/null so a detached gateway can
+  // outlive us, which means we never see its EADDRINUSE. Asking the OS whether
+  // we could bind is what replaces reading that.
+  check("a port nobody holds is available", (await portAvailable(dead)) === true);
+  check("a wedged listener's port is not available", (await portAvailable(wedgedPort)) === false);
+  check("a healthy gateway's port is not available", (await portAvailable(healthyPort)) === false);
+  check("checking availability does not steal the port", (await portAvailable(dead)) === true);
+
+  // ── freePort hands back something actually bindable ──
+  const p = await freePort();
+  const bound = await new Promise((resolve) => {
+    const s = net.createServer();
+    s.once("error", () => resolve(false));
+    s.listen(p, "127.0.0.1", () => s.close(() => resolve(true)));
+  });
+  check("freePort returns a bindable port", bound === true);
+  check("freePort does not return the well-known port", p !== 20128);
+
+  for (const s of [wedged, healthy, guarded, broken]) s.close();
+  console.log(fails ? `\n❌ ${fails} FAILED` : "\n✅ SIDECAR PORT RECOVERY TEST PASSED");
+  process.exit(fails ? 1 : 0);
+})().catch((e) => { console.error("crash:", e.message); process.exit(1); });