Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions lib/focus/cmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> {
if (!env[OUR_SURFACE_ENV]) return false;
return pathExists(await resolveSocketPath(env));
return probeSocket(await resolveSocketPath(env));
}

async start(): Promise<void> {
Expand Down
24 changes: 16 additions & 8 deletions lib/focus/herdr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
return probeSocket(path);
}

async start(): Promise<void> {
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 });
Expand Down
37 changes: 37 additions & 0 deletions lib/focus/socket.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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));
});
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 14 additions & 8 deletions tests/focus/cmux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
53 changes: 53 additions & 0 deletions tests/focus/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

/** A real listening socket; `probeSocket(path)` returns true while it's up. */
export async function listeningSocket(prefix = "cursor-focus"): Promise<LiveSocket> {
const dir = mkdtempSync(join(tmpdir(), `${prefix}-`));
const path = join(dir, "sock");
const server: Server = createServer();
await new Promise<void>((resolve) => server.listen(path, resolve));
return {
path,
close: () =>
new Promise<void>((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<LiveSocket> {
const dir = mkdtempSync(join(tmpdir(), `${prefix}-`));
const path = join(dir, "sock");
writeFileSync(path, "");
return {
path,
close: async () => {
rmSync(dir, { recursive: true, force: true });
},
};
}
35 changes: 34 additions & 1 deletion tests/focus/herdr.test.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
54 changes: 28 additions & 26 deletions tests/focus/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
42 changes: 42 additions & 0 deletions tests/focus/socket.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading