From be9040742bc02e2b828ba10dc7b2bbe0bb78d3c3 Mon Sep 17 00:00:00 2001 From: inquerium Date: Mon, 27 Jul 2026 21:29:03 -0500 Subject: [PATCH 1/6] feat: copy-on-select, add-to-chat, and collapsed pastes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlighting agent output now copies it straight to the clipboard the way a terminal does, and a floating pill (or ⌘L / Ctrl+L) quotes it into the composer as visible "> " lines. Copying goes through the main process: the renderer's async clipboard API needs focus plus a permission grant, and neither holds while a drag-select is still in flight. Scoped to the transcript — selecting in the composer, a project field, or a modal means editing, not quoting. Toggle with /copy on|off, persisted in prefs.json. A textarea can't style its own contents, so the composer paints transparent text over a
 mirror that re-renders the same string with quoted lines
dimmed and paste tokens accented. Every path that changes input.value goes
through grow(), so the mirror re-renders in one place instead of a dozen
call sites. Two traps worth knowing: 
 parsing swallows a leading
newline, so every line is wrapped in a span or a blank first line shifts
the mirror off by one; and --sel has to stay translucent, or the textarea's
selection would paint over the text it is meant to tint.

Pasting or quoting more than 6 lines / 800 chars collapses to
"[Pasted text #1 +322 lines]". The body is held aside and spliced back in
on the way to the model. The token is one unit, not 27 characters: a single
Backspace or Delete touching it removes the whole block along with the
trailing space, so the keypress right after pasting undoes the paste.
Editing a token apart drops its body — what gets sent is always what you
can see. After submitting, 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 survive a restart.

Two fixes fell out along the way: the pill dismissed itself whenever the
transcript auto-scrolled (it follows the selection now), and a message
carrying only attachments and no typed text refused to send.

Verified by test/selection.js, which drives the real renderer through
OMNIWORK_UI_TEST — 27 assertions covering the pill, quote insertion, both
collapse paths, atomic delete, and suppression inside editable fields —
then reads the system clipboard to prove copy-on-select actually wrote to
it. Mirror alignment is asserted as zero scrollHeight drift across six
layout traps (leading/trailing blank line, unbreakable 400-char run, heavy
wrapping, quotes, tokens). Live end-to-end: quoted 322 lines with a probe
buried at line 200, asked what line 200 was, got it back; restarted the
app and the block came back from disk with all 322 lines intact.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 CHANGELOG.md         |  20 ++++
 CONTRIBUTING.md      |   6 +
 README.md            |   2 +
 electron/main.js     |  17 ++-
 electron/preload.js  |   4 +-
 electron/sessions.js |  10 +-
 package.json         |   1 +
 renderer/app.js      | 265 +++++++++++++++++++++++++++++++++++++++++--
 renderer/index.html  |   7 +-
 renderer/styles.css  |  29 ++++-
 test/selection.js    | 252 ++++++++++++++++++++++++++++++++++++++++
 11 files changed, 590 insertions(+), 23 deletions(-)
 create mode 100644 test/selection.js

diff --git a/CHANGELOG.md b/CHANGELOG.md
index fabf962..66101e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,26 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Added
 
+- **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`.
 - **`npm run connect`** — wires OmniWork into Claude Code as a delegate target: registers the
   `omniwork` MCP server for the current user and adds a section to the global
   `~/.claude/CLAUDE.md` describing when delegating is worth it.
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 af401cb..130087c 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,8 @@ delegate** *inside* Claude Code or Codex.
 | 🔌 **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. |
 | 🌐 **Web-aware** | `web_fetch` reads pages/APIs; `open_url` opens links in your browser. |
+| ✂️ **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. |
 
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/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/package.json b/package.json
index 7cf5cad..36e55e1 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
     "test:mcp": "node test/mcp.js",
     "test:scheduler": "node test/scheduler.js",
     "test:approvals": "node test/approvals.js",
+    "test:selection": "node test/selection.js",
     "mcp": "node electron/mcp-server.js"
   },
   "bin": {
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/selection.js b/test/selection.js new file mode 100644 index 0000000..97ca903 --- /dev/null +++ b/test/selection.js @@ -0,0 +1,252 @@ +// 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; }
+}
+
+(async () => {
+  const scriptPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ow-sel-')), 'page.js');
+  fs.writeFileSync(scriptPath, page);
+  const clipboardTestable = writeClipboard(SENTINEL);
+
+  const electron = require.resolve('electron/cli.js');
+  const child = spawn(process.execPath, [electron, path.join(__dirname, '..')], {
+    env: { ...process.env, OMNIWORK_UI_TEST: scriptPath },
+    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 kill = setTimeout(() => { child.kill('SIGKILL'); resolve(null); }, 120_000);
+    const poll = setInterval(() => {
+      const m = buf.match(/\[ui-test\] (.+)/);
+      const err = buf.match(/\[ui-test-error\] (.+)/);
+      if (m || err) {
+        clearInterval(poll); clearTimeout(kill);
+        // Let the renderer's last IPC (the /copy on prefs write) land.
+        setTimeout(() => { child.kill('SIGKILL'); resolve(m ? JSON.parse(m[1]) : { error: err[1] }); }, 500);
+      }
+    }, 250);
+  });
+
+  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;
+
+  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); });

From ed6f35fd4d98c6f11ddfab0a139c4fceb263c637 Mon Sep 17 00:00:00 2001
From: inquerium 
Date: Tue, 28 Jul 2026 15:27:29 -0500
Subject: [PATCH 2/6] fix: test/selection.js orphaned the gateway sidecar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The test killed Electron with SIGKILL, which cannot be trapped — so the
shutdown path in main.js never ran, and the gateway (spawned detached, in
its own process group) survived as an orphan reparented to init. It kept
holding port 20128 while spinning at 100% CPU without serving requests, so
the next app launch adopted a sidecar that never answers and sat on
"engine error" indefinitely. main.js already handles SIGTERM/SIGINT/SIGHUP
for exactly this reason; the test was the one path bypassing it.

Shutdown is now SIGTERM with an 8s SIGKILL fallback, and the run asserts
nothing is still listening on its port afterwards, so a regression fails
the test instead of quietly leaking a process.

The run also shared the real profile: it drove the app against the default
port and userData dir, so it fought with any copy of OmniWork the developer
had open and rewrote their prefs.json via /copy off|on. It now gets its own
--user-data-dir and OMNIWORK_GATEWAY_PORT.

Worth knowing: #watchdog only arms after the gateway reaches ready, so a
boot that fails outright is not retried — "engine error" needs a relaunch.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 test/selection.js | 41 ++++++++++++++++++++++++++++++++---------
 1 file changed, 32 insertions(+), 9 deletions(-)

diff --git a/test/selection.js b/test/selection.js
index 97ca903..b86547d 100644
--- a/test/selection.js
+++ b/test/selection.js
@@ -191,14 +191,30 @@ function writeClipboard(text) {
   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, '..')], {
-    env: { ...process.env, OMNIWORK_UI_TEST: scriptPath },
+  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 = '';
@@ -206,17 +222,24 @@ function writeClipboard(text) {
   child.stderr.on('data', (d) => { buf += d; });
 
   const result = await new Promise((resolve) => {
-    const kill = setTimeout(() => { child.kill('SIGKILL'); resolve(null); }, 120_000);
+    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) {
-        clearInterval(poll); clearTimeout(kill);
-        // Let the renderer's last IPC (the /copy on prefs write) land.
-        setTimeout(() => { child.kill('SIGKILL'); resolve(m ? JSON.parse(m[1]) : { error: err[1] }); }, 500);
-      }
+      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));
@@ -245,7 +268,7 @@ function writeClipboard(text) {
     result.pillSuppressedInComposer && result.pillSuppressedInProjectField &&
     result.pillReshown && result.pillHiddenOnDeselect &&
     result.copyCommandFound && result.copyButtonWhenOff &&
-    clipboardOk;
+    clipboardOk && !leaked;
 
   console.log('\n' + (ok ? '✅ SELECTION TEST PASSED (copy-on-select + add to chat)' : '❌ FAILED'));
   process.exit(ok ? 0 : 1);

From 85b3b34eb525f1001fd8e50327783c9aec6d2b77 Mon Sep 17 00:00:00 2001
From: inquerium 
Date: Thu, 30 Jul 2026 14:51:38 -0500
Subject: [PATCH 3/6] perf: delegation was paying for work nobody was watching
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Delegating to OmniWork from Claude Code took far longer than the work
justified. Three causes, none of them the model.

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, since it lands on whichever provider is available. Nobody reads the
token stream during delegation, so that fallback bought nothing and cost 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: only their tool labels and final summary are
ever read.

The gateway also booted lazily on the *first* delegate call, so the caller
paid the entire OmniRoute cold start — tens of seconds, or an engine
download on lite builds — before any work began. It now starts in the
background at `initialize`, overlapping with the host reading the tool list
and deciding what to do.

Finally, delegation reported nothing until it was completely finished, so a
slow call and a hung one looked identical from the outside. Delegate calls
now emit `notifications/progress` per step and per finished subagent, and a
stalled turn is cut off at OMNIWORK_DELEGATE_TIMEOUT_MS (10 min) and returns
its partial work rather than hanging until the client gives up.

Gateway, skills, memory, and project wiring move to electron/headless.js so
the ACP server can share them, and both headless servers now honour
OMNIWORK_BASE_URL / OMNIWORK_API_KEY to run against an OpenAI-compatible
endpoint other than the bundled gateway.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 electron/agent.js      |  13 ++++-
 electron/headless.js   | 100 ++++++++++++++++++++++++++++++++++++++
 electron/mcp-server.js | 107 ++++++++++++++++++-----------------------
 test/mcp.js            |   7 ++-
 4 files changed, 166 insertions(+), 61 deletions(-)
 create mode 100644 electron/headless.js

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/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/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 = "";

From f3cee65abd5098c1257cdb45da9a5a979b4e46ab Mon Sep 17 00:00:00 2001
From: inquerium 
Date: Thu, 30 Jul 2026 14:51:55 -0500
Subject: [PATCH 4/6] feat: speak ACP, so any harness can drive OmniWork
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The MCP server makes OmniWork a *tool* your agent calls. This makes it an
*agent* another harness runs: OpenClaw, acpx, Zed, Neovim — anything that
speaks the Agent Client Protocol. The harness owns the UI, the approval
prompts, and the transcript; OmniWork does the work on free models.

  ~/.acpx/config.json
  { "agents": { "omniwork": { "argv": ["npx", "-y", "omniwork-acp"] } } }

The agent loop already emitted everything the protocol wants, so most of
this is translation. Text and reasoning stream as message chunks; every tool
call reports pending -> in_progress -> completed with its ACP kind and
source locations; write_file and edit_file arrive as native diffs rather
than a byte count, computed before the write lands because that is the only
moment the old content still exists. Gated calls become
session/request_permission, where "allow always" flips the session to auto
mode instead of silently prompting again on the next call. The Agent Deck
surfaces as N live parallel tool calls rather than one opaque block, and
installed skills are published as slash commands.

Approval modes (ask, edits, auto, plan) are exposed as ACP session modes.
`ask` is the default: the whole point of the protocol is that the client
owns the permission boundary, so it should get the chance to prompt rather
than have us run commands unattended in someone's editor.
OMNIWORK_ACP_MODE overrides it.

Hand-rolled JSON-RPC rather than the official SDK, which is ESM-only while
this app is CommonJS — and the stdio loop was already written for the MCP
server. Unlike that one this connection is bidirectional, since the agent
sends requests to the client, so outbound ids get a pending map.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 electron/acp-server.js | 508 +++++++++++++++++++++++++++++++++++++++++
 package.json           |   7 +-
 test/acp.js            | 108 +++++++++
 3 files changed, 621 insertions(+), 2 deletions(-)
 create mode 100644 electron/acp-server.js
 create mode 100644 test/acp.js

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/package.json b/package.json
index 36e55e1..b1853e0 100644
--- a/package.json
+++ b/package.json
@@ -33,10 +33,13 @@
     "test:scheduler": "node test/scheduler.js",
     "test:approvals": "node test/approvals.js",
     "test:selection": "node test/selection.js",
-    "mcp": "node electron/mcp-server.js"
+    "test:acp": "node test/acp.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/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); });

From 8e507e9198844269ec0b9d00f3c874442d1f8c2c Mon Sep 17 00:00:00 2001
From: inquerium 
Date: Thu, 30 Jul 2026 14:52:12 -0500
Subject: [PATCH 5/6] fix: a wedged gateway no longer takes the whole app down
 with it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

If a previous run left an OmniRoute process bound to port 20128 but no
longer answering, every subsequent boot failed — slowly, then reported only
"Gateway exited (code 1)". Delegation on top of that looks identical to
delegation being merely slow, which is how it went unnoticed.

Two bugs. 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 300s default; the loop's own 90s 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 politely asking a port owned by somebody else how it was getting on.

Health checks are now bounded per request, a dead child ends the wait
immediately instead of burning the full budget, and EADDRINUSE is handled as
its own case: re-probe patiently first, since the holder is often a healthy
gateway that was simply slower than the 2s startup probe, and adopt it if
so. If it really is wedged, start on an OS-assigned free port instead. The
port therefore becomes per-instance; everything already read baseUrl and
dashboardUrl off the Gateway object, so only discovery still assumes the
well-known port, which is correct.

It deliberately does not kill the holder. That process may not belong to
OmniWork at all, and taking a port by force isn't a decision to make on the
user's behalf.

test/sidecar.js covers the probe directly — closed, wedged, healthy, 401,
and 500 — without booting OmniRoute, so the regression stays cheap to check.
Verified end to end against the real failure: a wedged listener pinned to
20128 is now detected immediately, re-probed, and recovered onto a fallback
port, healthy in 30s.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 electron/sidecar.js | 132 +++++++++++++++++++++++++++++++++++++-------
 package.json        |   1 +
 test/sidecar.js     |  64 +++++++++++++++++++++
 3 files changed, 177 insertions(+), 20 deletions(-)
 create mode 100644 test/sidecar.js

diff --git a/electron/sidecar.js b/electron/sidecar.js
index bd993aa..5f1ec99 100644
--- a/electron/sidecar.js
+++ b/electron/sidecar.js
@@ -10,10 +10,36 @@
 // 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; }
+}
+
+// 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,7 +106,26 @@ class Gateway {
     this.proc = null;
     this.ready = false;
     this.adopted = false; // true when we reused a gateway we didn't spawn
-    this.baseUrl = BASE_URL;
+    // Normally the well-known port, so other OmniWork processes can discover us.
+    // Only moves if that port turns out to be unusable (see start()).
+    this.port = PORT;
+    this.baseUrl = `http://${HOST}:${this.port}/v1`;
+  }
+
+  #setPort(port) {
+    this.port = port;
+    this.baseUrl = `http://${HOST}:${port}/v1`;
+  }
+
+  // Something already holds the port. Give it a chance to be a gateway that was
+  // simply slower to come up than the startup probe allowed.
+  async #probePatiently(port, budgetMs) {
+    const until = Date.now() + budgetMs;
+    while (Date.now() < until) {
+      if (await probeGateway(port, 3000)) return true;
+      await sleep(1000);
+    }
+    return false;
   }
 
   status(state, detail) {
@@ -91,10 +136,7 @@ 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);
   }
 
   async start() {
@@ -150,10 +192,8 @@ class Gateway {
     // Ensure no stray Electron-as-node flag leaks into the child.
     delete env.ELECTRON_RUN_AS_NODE;
 
-    // Corrupt gateway storage (e.g. after a hard kill) makes the server listen
-    // but never turn healthy. If the first boot times out, quarantine the DB
-    // and try once more with a fresh one.
-    for (let attempt = 0; ; attempt++) {
+    // Boot failures come in three flavours, each with its own recovery.
+    for (let attempt = 0; attempt < 4; attempt++) {
       this.#spawnServer(serverEntry, omniDir, env);
       try {
         await this.#waitHealthy();
@@ -161,34 +201,78 @@ class Gateway {
         return this.baseUrl;
       } catch (e) {
         this.stop();
+
+        // 1. Someone else already holds the port, so our child died on
+        //    EADDRINUSE. Either a healthy gateway that was slower than the 2s
+        //    startup probe — adopt it — or a wedged process that will never
+        //    answer. In that second case move to another port rather than
+        //    failing: the holder may not be ours to kill, and killing an
+        //    unknown process to take its port is not a decision to make
+        //    automatically.
+        if (this._portBusy) {
+          this.status("boot", "Port in use — looking for a healthy gateway…");
+          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;
+          }
+          if (!this._movedPort) {
+            this._movedPort = true;
+            const p = await freePort();
+            this.#setPort(p);
+            this.status("boot", `Port ${PORT} is blocked — starting on ${p}…`);
+            continue;
+          }
+          throw new Error(`port ${PORT} is held by an unresponsive process, and the fallback port failed too`);
+        }
+
+        // 2. Corrupt gateway storage (e.g. after a hard kill) makes the server
+        //    listen but never turn healthy. Quarantine the DB and try once more.
         const db = path.join(gwDataDir, "storage.sqlite");
-        if (attempt === 0 && fs.existsSync(db)) {
+        if (!this._triedDbRecovery && fs.existsSync(db)) {
+          this._triedDbRecovery = true;
           this.status("boot", "Recovering gateway storage…");
           try { fs.renameSync(db, db + ".corrupt-" + Date.now() + ".bak"); } catch {}
           continue;
         }
+
+        // 3. Nothing we know how to fix.
         throw e;
       }
     }
+    throw new Error("gateway failed to start after repeated attempts");
   }
 
   #spawnServer(serverEntry, omniDir, env) {
+    this._portBusy = false;
+    this._exited = false;
     // detached: gives the child its own process group so stop() can take down
     // any workers Next.js spawned, not just the parent.
     this.proc = spawn(this.nodeBinary, [serverEntry], {
       cwd: path.join(omniDir, "dist"),
-      env,
+      env: { ...env, PORT: String(this.port) },
       stdio: ["ignore", "pipe", "pipe"],
       detached: process.platform !== "win32",
     });
 
-    this.proc.stdout.on("data", (d) => this.#log(d));
-    this.proc.stderr.on("data", (d) => this.#log(d));
+    // The child announces EADDRINUSE on its way out; catching it here is what
+    // lets start() tell "port taken" apart from "server is broken".
+    const scan = (d) => {
+      if (d.toString().includes("EADDRINUSE")) this._portBusy = true;
+      this.#log(d);
+    };
+    this.proc.stdout.on("data", scan);
+    this.proc.stderr.on("data", scan);
     this.proc.on("exit", (code) => {
       this.ready = false;
-      if (code && code !== 0) this.status("error", `Gateway exited (code ${code})`);
+      this._exited = true;
+      // A recoverable startup failure is reported by start(), which knows what
+      // it's going to do about it. Only surface unexpected deaths here.
+      if (code && code !== 0 && !this._portBusy) 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
@@ -216,11 +300,19 @@ 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're doing.
+      if (this._exited) {
+        throw new Error(this._portBusy ? `port ${this.port} already in use` : "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 forever — the loop's own
+        // deadline is only checked between iterations, so it never fires.
+        const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
         if (res.ok || res.status === 401) {
           this.ready = true;
           this.status("ready", "OmniRoute · free models");
@@ -230,14 +322,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() {
@@ -262,4 +354,4 @@ class Gateway {
   }
 }
 
-module.exports = { Gateway, PORT, BASE_URL };
+module.exports = { Gateway, PORT, BASE_URL, probeGateway, freePort };
diff --git a/package.json b/package.json
index b1853e0..32c0d62 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
     "test:approvals": "node test/approvals.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"
   },
diff --git a/test/sidecar.js b/test/sidecar.js
new file mode 100644
index 0000000..20fd674
--- /dev/null
+++ b/test/sidecar.js
@@ -0,0 +1,64 @@
+// 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, 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);
+
+  // ── 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); });

From 030dc60738c8eb38bfa6eeb0aba8af4d88ece00d Mon Sep 17 00:00:00 2001
From: inquerium 
Date: Thu, 30 Jul 2026 14:52:18 -0500
Subject: [PATCH 6/6] docs: the ACP server, and what changed underneath it

README gets a section on driving OmniWork from OpenClaw, acpx, or Zed, with
the config for both, a table of what the harness actually gets, and the new
environment variables. CHANGELOG records the ACP server, the delegation
speedups, and the gateway port recovery.

Co-Authored-By: Claude Opus 5 (1M context) 
---
 CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 README.md    | 57 ++++++++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 113 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66101e2..0a9b1c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,22 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### 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
@@ -41,7 +57,49 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - **`npm run setup`** — runs `doctor`, then offers the Claude Code integration. Skip the
   prompt with `--no-connect`, or accept both with `--yes`.
 
-## [0.9.1] — 2026-07-26
+### 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, a dead child ends the wait immediately, and
+  `EADDRINUSE` is handled as its own case: re-probe patiently first, since the holder is often a
+  healthy gateway that was merely slower than the 2 s startup probe, and adopt it if so. If it
+  really is wedged, the sidecar starts on an OS-assigned free port instead. It 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.
 
 First macOS build. Everything already ran on Windows and Linux, but the app was
 effectively unusable when launched as a Mac app — and the Mac artifact it produced would
diff --git a/README.md b/README.md
index 130087c..e9525b4 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ delegate** *inside* Claude Code or Codex.
 | 🃏 **Agent Deck** | One agent **fans work out to parallel subagents** — watch them live as a deck of cards. |
 | 🔌 **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. |
 | 🌐 **Web-aware** | `web_fetch` reads pages/APIs; `open_url` opens links in your browser. |
 | ✂️ **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. |
@@ -147,6 +148,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).
@@ -200,6 +242,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
@@ -208,7 +251,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.
 
@@ -221,7 +266,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 |
@@ -239,6 +288,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/
@@ -247,7 +298,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