diff --git a/README.md b/README.md index f728ccf..0bcf701 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,13 @@ If `focus-events` is off, the extension degrades gracefully to static mode and n ### cmux -Uses cmux's v2 socket API to call the purpose-built `debug.terminal.is_focused` RPC with our surface id (`CMUX_SURFACE_ID`), polled every ~300 ms. The server authoritatively resolves the full window→workspace→pane→surface focus hierarchy, so the client never reconstructs it. Auto-detected when `CMUX_SURFACE_ID` is present and the cmux control socket is reachable (`CMUX_SOCKET_PATH`, or `~/Library/Application Support/cmux/last-socket-path`, or `/tmp/cmux-debug.sock`, or `~/Library/Application Support/cmux/cmux.sock`, or `/tmp/cmux.sock`). Precedence: `tmux` > `cmux` > `herdr` > `static`. +Uses cmux's v2 socket API to call the purpose-built `debug.terminal.is_focused` RPC with our surface id (`CMUX_SURFACE_ID`), polled every ~300 ms. The server authoritatively resolves the full window→workspace→pane→surface focus hierarchy, so the client never reconstructs it. Auto-detected when `CMUX_SURFACE_ID` is present and the cmux control socket is **live** — a real connection is opened to verify a listener is accepting (`CMUX_SOCKET_PATH`, or `~/Library/Application Support/cmux/last-socket-path`, or `/tmp/cmux-debug.sock`, or `~/Library/Application Support/cmux/cmux.sock`, or `/tmp/cmux.sock`). Precedence: `tmux` > `cmux` > `herdr` > `static`. > **⚠️ v0.1.1 cmux adapter is built from `manaflow-ai/cmux` source (`tests_v2/cmux.py`, `docs/events.md`, `docs/cli-contract.md`) and unit-tested against a mocked RPC, but not verified against a live cmux session.** The wire envelope, the `debug.terminal.is_focused` method + params + result shape, and the socket-path resolution order are confirmed against the Python client source (not just prose docs). The one assumption: that `CMUX_SURFACE_ID` is always injected into terminal surfaces (cli-contract.md states it is the "Default surface context inside cmux terminals"). Debug-build glob socket discovery (`/tmp/cmux-debug-*.sock`, `cmux*.sock`) is not implemented in v0.1.1. See `lib/focus/cmux.ts`. ### herdr -Uses herdr's local socket API (`session.snapshot` + `events.subscribe`); auto-detected when the herdr socket is present (`HERDR_SOCKET_PATH`, `HERDR_SESSION`, or `~/.config/herdr/herdr.sock`). +Uses herdr's local socket API (`session.snapshot` + `events.subscribe`); auto-detected when the herdr socket is **live** — a real connection is opened to verify a listener is accepting (`HERDR_SOCKET_PATH`, `HERDR_SESSION`, or `~/.config/herdr/herdr.sock`). A stale socket file left behind by a crashed/killed herdr is rejected, not mistaken for a running server. > **⚠️ v0.1 herdr adapter is built from `herdr.dev/docs/socket-api` and unit-tested against a mocked socket, but not verified against a live herdr session.** Three constants (the our-own-pane-id env var, the `events.subscribe` event names, and the focus-event field name) are documented assumptions — confirm/adjust them in a real herdr pane with `env | grep -i herdr` and `herdr api schema --json`. See `lib/focus/herdr.ts`. @@ -87,6 +87,10 @@ No multiplexer detected (bare Ghostty/Kitty/iTerm2/Alacritty, or unknown). The c > **⚠️ v0.2.0 hardware mode + truecolor are built from the pi-tui source + spec, unit-tested against mocks, but not verified against a live Ghostty+tmux pane in this release.** Hardware mode is Ghostty-targeted (DECSCUSR + OSC 12 are standard but only verified on Ghostty here); other terminals get the `fake` default. In 256-color theme mode, OSC 12 is skipped (no exact hex) and the terminal uses its configured cursor color. See `lib/editor.ts` + `lib/render.ts`. +## Troubleshooting + +- **`error: connect ECONNREFUSED …/herdr.sock` (or `cmux.sock`) on startup** — a multiplexer crashed or was killed and left its socket file behind on disk. v0.2.3+ detects this by probing the socket for a live listener (not just checking the file exists) and silently falls through to the next provider, so the error should not appear. On older versions, remove the stale leftover: `rm ~/.config/herdr/herdr.sock` (herdr recreates it on next start). + ## The char-hidden constraint A fake cursor *is* the cell — ANSI has no partial-cell overlay. So the **`bar`** (focused) and **`hollow`**/**`outline`** (unfocused) styles render a glyph that **hides the character at the cursor position** while the cursor sits on it; the character reappears when the cursor moves. This is a terminal limitation, not a bug. `block`, `underline`, `dim`, `hide`, and **`highlight`** (char-preserving colored undercurl) preserve the character. In `hardware` mode the focused state uses the native terminal cursor (no fake cell), sidestepping this entirely. diff --git a/lib/focus/cmux.ts b/lib/focus/cmux.ts index 90f9660..67581c0 100644 --- a/lib/focus/cmux.ts +++ b/lib/focus/cmux.ts @@ -44,6 +44,7 @@ import { connect, type Socket } from "node:net"; import { access, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { probeSocket } from "./socket.ts"; import type { FocusProvider } from "./index.ts"; const POLL_MS = 300; @@ -169,10 +170,14 @@ export class CmuxFocusProvider implements FocusProvider { } = {}, ) {} - /** Detect: CMUX_SURFACE_ID present AND a resolvable socket exists. */ + /** + * Detect: CMUX_SURFACE_ID present AND the resolved socket is LIVE. + * Probes liveness (not mere existence) so a crashed cmux's stale socket + * file doesn't get picked and then fail with ECONNREFUSED at start(). + */ static async detect(env: NodeJS.ProcessEnv = process.env): Promise { if (!env[OUR_SURFACE_ENV]) return false; - return pathExists(await resolveSocketPath(env)); + return probeSocket(await resolveSocketPath(env)); } async start(): Promise { diff --git a/lib/focus/herdr.ts b/lib/focus/herdr.ts index 6e81e06..a785be8 100644 --- a/lib/focus/herdr.ts +++ b/lib/focus/herdr.ts @@ -17,9 +17,9 @@ * events.subscribe (long-lived; emits resource events incl. focus changes). */ import { connect, type Socket } from "node:net"; -import { access } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { probeSocket } from "./socket.ts"; import type { FocusProvider } from "./index.ts"; // ⚠️ ASSUMPTION — confirm via `env | grep -i herdr` inside a herdr pane. @@ -92,18 +92,26 @@ export class HerdrFocusProvider implements FocusProvider { }, ) {} + // Liveness, not just existence: a crashed herdr leaves its socket file on + // disk, and `access()`-based detection would mistake the stale leftover for + // a running server (→ ECONNREFUSED at start()). probeSocket opens a real + // connection, so a dead file is rejected at once. static async detect(path: string = defaultSocketPath()): Promise { - try { - await access(path); - return true; - } catch { - return false; - } + return probeSocket(path); } async start(): Promise { this.ourPaneId = process.env[OUR_PANE_ENV]; - this.socket = await this.opts.socket(); + // Defensive: if the socket is dead/stale (e.g. an explicit `/cursor + // provider herdr` pointed at a crashed daemon), swallow the connect + // failure and degrade to always-focused — matching cmux's resilience. + // Better than throwing in session_start and breaking the whole extension. + try { + this.socket = await this.opts.socket(); + } catch { + this.socket = undefined; + return; + } this.socket.onMessage((line) => this.handle(line)); this.send("session.snapshot", {}); this.send("events.subscribe", { events: SUBSCRIBE_EVENTS }); diff --git a/lib/focus/socket.ts b/lib/focus/socket.ts new file mode 100644 index 0000000..3ab8747 --- /dev/null +++ b/lib/focus/socket.ts @@ -0,0 +1,37 @@ +/** + * Unix-domain socket liveness probe, shared by the focus providers. + * + * Why this exists: `fs.access` / `stat` only prove a socket FILE exists on + * disk. When a multiplexer server dies hard (crash, `kill -9`, reboot) it + * leaves its socket file behind — the entry remains but nothing is + * `accept`-ing, so `connect()` fails with ECONNREFUSED. A detect() based on + * file-existence therefore mistakes a stale leftover for a running server and + * the provider's `start()` then throws the ECONNREFUSED the user sees. + * + * This opens a real connection and closes it immediately. `connect` resolves + * only when a listener accepts; a stale/missing/non-socket path rejects at + * once. Never throws — returns true on connect, false on any error/timeout — + * so it is safe to drop into detect() probes. + * + * Separation of concerns: path RESOLUTION (env → marker → candidates) stays + * existence-based and lives in each provider; LIVENESS lives here. Resolution + * answers "where should I look?", a probe answers "is anyone home?". + */ +import { connect } from "node:net"; + +export function probeSocket(path: string, timeoutMs = 300): Promise { + return new Promise((resolve) => { + const sock = connect(path); + let settled = false; + const finish = (ok: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + sock.destroy(); + resolve(ok); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + sock.once("connect", () => finish(true)); + sock.once("error", () => finish(false)); + }); +} diff --git a/package.json b/package.json index c06b92d..af0dc20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/cursor", - "version": "0.2.2", + "version": "0.2.3", "description": "Focus-aware, customizable editor cursor for the pi coding agent (truecolor + Ghostty/tmux deep; tmux + cmux + herdr; static fallback).", "keywords": [ "pi-package", diff --git a/tests/focus/cmux.test.ts b/tests/focus/cmux.test.ts index 460edf7..8ce3ce9 100644 --- a/tests/focus/cmux.test.ts +++ b/tests/focus/cmux.test.ts @@ -159,14 +159,20 @@ test("detect() = false when CMUX_SURFACE_ID set but socket missing", async () => assert.equal(await CmuxFocusProvider.detect(), false); }); -test("detect() = true when CMUX_SURFACE_ID set and socket path exists", async () => { - const { writeFileSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const sock = join(tmpdir(), "fake-cmux-cursor.sock"); - writeFileSync(sock, ""); +test("detect() = true when CMUX_SURFACE_ID set and a LIVE socket is listening", async () => { + const { listeningSocket } = await import("./helpers.ts"); + const s = await listeningSocket("cmux-live"); process.env.CMUX_SURFACE_ID = "surf-1"; - process.env.CMUX_SOCKET_PATH = sock; + process.env.CMUX_SOCKET_PATH = s.path; assert.equal(await CmuxFocusProvider.detect(), true); - rmSync(sock, { force: true }); + await s.close(); +}); + +test("detect() = false when CMUX_SURFACE_ID set but socket is a stale leftover (no listener)", async () => { + const { staleSocketFile } = await import("./helpers.ts"); + const s = await staleSocketFile("cmux-stale"); + process.env.CMUX_SURFACE_ID = "surf-1"; + process.env.CMUX_SOCKET_PATH = s.path; + assert.equal(await CmuxFocusProvider.detect(), false); + await s.close(); }); \ No newline at end of file diff --git a/tests/focus/helpers.ts b/tests/focus/helpers.ts new file mode 100644 index 0000000..be122f3 --- /dev/null +++ b/tests/focus/helpers.ts @@ -0,0 +1,53 @@ +/** + * Test helper: spin up a throwaway LISTENING Unix-domain socket in a temp dir. + * + * Used by focus-provider detect() tests, which must exercise the live path — + * `probeSocket` returns true only when a server is accept()-ing. A stale + * leftover (a file with no listener) is the bug we harden against, so the + * "detect = true" tests need a real listener, not a `writeFileSync` stub. + */ +import { createServer, type Server } from "node:net"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export interface LiveSocket { + path: string; + close: () => Promise; +} + +/** A real listening socket; `probeSocket(path)` returns true while it's up. */ +export async function listeningSocket(prefix = "cursor-focus"): Promise { + const dir = mkdtempSync(join(tmpdir(), `${prefix}-`)); + const path = join(dir, "sock"); + const server: Server = createServer(); + await new Promise((resolve) => server.listen(path, resolve)); + return { + path, + close: () => + new Promise((resolve) => { + server.close(() => { + rmSync(dir, { recursive: true, force: true }); + resolve(); + }); + }), + }; +} + +/** + * A stale socket lookalike: a filesystem entry at `path` with NO listener. + * Reproduces the crashed-daemon leftover that `fs.access`-based detect() + * mistakenly trusted. Uses a regular file (good enough — `connect()` to a + * non-socket path errors the same way a stale socket would). + */ +export async function staleSocketFile(prefix = "cursor-stale"): Promise { + const dir = mkdtempSync(join(tmpdir(), `${prefix}-`)); + const path = join(dir, "sock"); + writeFileSync(path, ""); + return { + path, + close: async () => { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/focus/herdr.test.ts b/tests/focus/herdr.test.ts index 7c55c58..a6213d3 100644 --- a/tests/focus/herdr.test.ts +++ b/tests/focus/herdr.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { HerdrFocusProvider, type HerdrSocket } from "../../lib/focus/herdr.ts"; +import { HerdrFocusProvider, type HerdrSocket, type SocketFactory } from "../../lib/focus/herdr.ts"; // Mock socket: captures sent lines, feeds queued inbound lines to the handler. function mockSocket() { @@ -63,6 +63,39 @@ test("detect() = false when socket file missing", async () => { assert.equal(await HerdrFocusProvider.detect("/tmp/definitely-missing-herdr-sock"), false); }); +test("detect() = false when a stale leftover socket file is present (no listener)", async () => { + const { staleSocketFile } = await import("./helpers.ts"); + const s = await staleSocketFile("herdr-stale"); + assert.equal(await HerdrFocusProvider.detect(s.path), false); + await s.close(); +}); + +test("detect() = true when a live socket is listening", async () => { + const { listeningSocket } = await import("./helpers.ts"); + const s = await listeningSocket("herdr-live"); + assert.equal(await HerdrFocusProvider.detect(s.path), true); + await s.close(); +}); + +test("start swallows connect failure (stale/explicit dead socket) → no throw, no onChange", async () => { + // Explicit `/cursor provider herdr` against a dead socket must degrade to + // always-focused rather than breaking session_start with ECONNREFUSED. + process.env.HERDR_PANE_ID = "w1:p1"; + const failingSocket: { socket: SocketFactory; socketPath: string } = { + socket: async () => { + throw new Error("connect ECONNREFUSED /path/to/herdr.sock"); + }, + socketPath: "/path/to/herdr.sock", + }; + let last: boolean | undefined; + const p = new HerdrFocusProvider((f) => { + last = f; + }, failingSocket); + await p.start(); // must not throw + assert.equal(last, undefined); // stayed focused=true, no onChange + await p.stop(); +}); + test("no our-pane-id env → falls back to snapshot focused_pane_id as our pane (assume focused at start)", async () => { delete process.env.HERDR_PANE_ID; const m = mockSocket(); diff --git a/tests/focus/index.test.ts b/tests/focus/index.test.ts index a580bd8..73ba374 100644 --- a/tests/focus/index.test.ts +++ b/tests/focus/index.test.ts @@ -31,45 +31,47 @@ test("tmux wins over cmux when both envs set", async () => { await p.stop(); }); -test("CMUX_SURFACE_ID + socket present → cmux (before herdr)", async () => { - const { writeFileSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const sock = join(tmpdir(), "fake-cmux-cursor.sock"); - writeFileSync(sock, ""); +test("CMUX_SURFACE_ID + LIVE socket → cmux (before herdr)", async () => { + const { listeningSocket } = await import("./helpers.ts"); + const s = await listeningSocket("idx-cmux"); process.env.CMUX_SURFACE_ID = "surf-1"; - process.env.CMUX_SOCKET_PATH = sock; + process.env.CMUX_SOCKET_PATH = s.path; const p = await autoDetect(() => {}); assert.equal(p.name, "cmux"); await p.stop(); - rmSync(sock, { force: true }); + await s.close(); }); -test("cmux wins over herdr when both detectable", async () => { - const { writeFileSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const sock = join(tmpdir(), "fake-cmux-over-herdr.sock"); - writeFileSync(sock, ""); +test("cmux wins over herdr when both detectable (live)", async () => { + const { listeningSocket } = await import("./helpers.ts"); + const s = await listeningSocket("idx-cmux-over-herdr"); process.env.CMUX_SURFACE_ID = "surf-1"; - process.env.CMUX_SOCKET_PATH = sock; - process.env.HERDR_SOCKET_PATH = sock; // same fake socket — herdr would also detect + process.env.CMUX_SOCKET_PATH = s.path; + process.env.HERDR_SOCKET_PATH = s.path; // same live socket — herdr would also detect const p = await autoDetect(() => {}); assert.equal(p.name, "cmux"); await p.stop(); - rmSync(sock, { force: true }); + await s.close(); }); -test("herdr socket env → herdr (when cmux not present)", async () => { - // herdr's detect() checks socket existence; point it at a real file so detect passes. - const { writeFileSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const sock = join(tmpdir(), "fake-herdr-cursor.sock"); - writeFileSync(sock, ""); - process.env.HERDR_SOCKET_PATH = sock; +test("herdr LIVE socket → herdr (when cmux not present)", async () => { + const { listeningSocket } = await import("./helpers.ts"); + const s = await listeningSocket("idx-herdr"); + process.env.HERDR_SOCKET_PATH = s.path; const p = await autoDetect(() => {}); assert.equal(p.name, "herdr"); await p.stop(); - rmSync(sock, { force: true }); + await s.close(); +}); + +test("stale herdr socket file (no listener) → falls through to static", async () => { + // Regression guard for the reported bug: a crashed herdr leaves its socket + // file on disk; detect() must NOT mistake the leftover for a live server. + const { staleSocketFile } = await import("./helpers.ts"); + const s = await staleSocketFile("idx-herdr-stale"); + process.env.HERDR_SOCKET_PATH = s.path; + const p = await autoDetect(() => {}); + assert.equal(p.name, "static"); + await p.stop(); + await s.close(); }); \ No newline at end of file diff --git a/tests/focus/socket.test.ts b/tests/focus/socket.test.ts new file mode 100644 index 0000000..34e8d43 --- /dev/null +++ b/tests/focus/socket.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { probeSocket } from "../../lib/focus/socket.ts"; +import { listeningSocket, staleSocketFile } from "./helpers.ts"; + +test("probeSocket: live listener → true", async () => { + const s = await listeningSocket("probe-live"); + assert.equal(await probeSocket(s.path), true); + await s.close(); +}); + +test("probeSocket: missing path → false (no throw)", async () => { + assert.equal(await probeSocket("/tmp/definitely-missing-probe-sock"), false); +}); + +test("probeSocket: stale leftover file (no listener) → false", async () => { + // The bug: file exists on disk but no server is accept()-ing. + const s = await staleSocketFile("probe-stale"); + assert.equal(await probeSocket(s.path), false); + await s.close(); +}); + +test("probeSocket: regular (non-socket) file → false", async () => { + const s = await staleSocketFile("probe-regular"); + assert.equal(await probeSocket(s.path), false); + await s.close(); +}); + +test("probeSocket: after server closes → false", async () => { + const s = await listeningSocket("probe-close"); + assert.equal(await probeSocket(s.path), true); + await s.close(); + assert.equal(await probeSocket(s.path), false); +}); + +test("probeSocket: explicit timeout on an unaccept-ing path is bounded", async () => { + // A non-existent path rejects immediately rather than timing out; this just + // asserts the timeoutMs knob is honoured and the promise resolves quickly. + const start = Date.now(); + assert.equal(await probeSocket("/tmp/another-missing-probe-sock", 50), false); + assert.ok(Date.now() - start < 500, "resolved well under 500ms"); +});