diff --git a/INSTALL.md b/INSTALL.md index aa472e87..c804eab3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -86,6 +86,54 @@ Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point: the browser terminal and your local terminal are two clients on one pane, and restarting the dashboard never stops a run. +### macOS + +Status: **code-audited, untested on a Mac.** The Mac-specific paths below are +covered by Linux-side unit tests only; the checklist at the end of this section +is what a Mac user runs to confirm them. + +```bash +brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too) +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub` +``` + +- **Node:** a plugin install needs **>= 22.5** (it stores data through + `node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher + pins the node that installed it and falls back to `node` on `PATH` when a + `brew upgrade` or `nvm` switch deletes that binary. +- **No systemd, so no auto-start on boot.** The server is a detached process + that runs until logout or reboot. It comes back at the next Claude Code + session start (within about 10 s), or by hand with `agenthub start`. A + discovery entry left over from before the reboot is ignored, so a recycled + pid can never pass for a live dashboard. +- **Started from a Claude Code session.** The server drops the variables that + describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`, + `CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not + inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`, + `CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that + an older build started keeps its dirty global env until it exits + (`tmux kill-server` once, with no run you care about open). +- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x` + becomes `/private/tmp/x` and a differently-cased path on a case-insensitive + volume becomes the one on disk — that is what sessions report, so they bind. + Lanes created before this change keep the string they were created with. +- **Account switching** uses the login Keychain item `Claude Code-credentials`; + macOS may ask once for permission the first time. +- **Option key** in the browser terminal types special characters (it is not + sent as Meta). + +**Mac checklist** — please run it and report the result on issue #22: + +1. Fresh plugin install from a Terminal `claude` session: `/agenthub-doctor` is all OK (the systemd row says not available) and the dashboard answers on `http://localhost:4820`. +2. `tmux show-environment -g | grep -i claude` prints nothing. +3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map. +4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form. +5. Changes panel: diff, stage and commit on a lane. +6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name. +7. `brew upgrade node && brew cleanup`, then `agenthub status` still works. +8. Reboot, open `claude`, and the dashboard comes back within about 10 s. +9. `/agenthub-update` completes and the server restarts on the new version. + --- ## Step 1 — Clone the repository diff --git a/README.md b/README.md index 6b2bff46..0910a82c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,12 @@ sudo pacman -S tmux # Arch Windows has no native tmux — run AgentHub under WSL2 and install it there. +macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node +>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is +no systemd, so the dashboard runs until logout/reboot and comes back at the +next Claude Code session, or by hand with `agenthub start`. Details and a +checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos). + Verify, both from your shell and from the server's point of view: ```bash diff --git a/README.vi.md b/README.vi.md index 96e13fef..157cb0ba 100644 --- a/README.vi.md +++ b/README.vi.md @@ -92,6 +92,12 @@ sudo pacman -S tmux # Arch Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó. +macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux` +(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`. +Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên +lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết +và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos). + Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server: ```bash diff --git a/docs/LANES.md b/docs/LANES.md index fbc30046..6610ae9e 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -22,7 +22,7 @@ agenthub lanes add --cwd /path/to/repo --title "My Feature" The lane is now ready to track work. When you run a Claude Code session in that directory (or a subdirectory), the dashboard will bind the session to the lane automatically by matching the cwd. -**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists). +**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists). A `cwd` that exists is stored by its real path (`fs.realpathSync.native`), so `/tmp/wt` on macOS is stored as `/private/tmp/wt` and a differently-cased path on a case-insensitive volume as the spelling on disk — the path a session reports. A path that does not exist yet keeps the string it was given; rows created before this rule keep theirs. To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository: diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 75eee39e..53de674e 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -128,13 +128,13 @@ check. milliseconds — the real work happens in a detached worker, so a session never waits on an install: -1. **Fast path** — recorded state matches this plugin build and a server is live → exit. +1. **Fast path** — recorded state matches this plugin build and a server is live → exit. "Live" means a discovery entry whose pid answers AND whose `startedAt` is after this boot (60 s margin): an entry left over from before a reboot names a pid the OS may have handed to an unrelated process, and without systemd (macOS) nothing else clears it. 2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)). 3. **Node gate** — refuse below 22.5 with one line (see Prerequisites). 4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers. 5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain. 6. **Legacy hook cleanup** — see above. -7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. +7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade). 8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line. 9. **Server** — on Linux with `systemd --user` available, install/refresh a `agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead @@ -235,7 +235,13 @@ server falls back to the old per-session detached spawn instead. On macOS, Windows, or a Linux box without systemd, nothing changes: the server is a detached spawn started fresh on the next `SessionStart` after a -reboot, same as always. +reboot, same as always. That spawn inherits the Claude Code session's env, so +`server/index.js` deletes the parent-session variables (`CLAUDECODE`, +`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …) +before anything else runs — otherwise tmux hands them to every lane pane. It is +a denylist: user configuration such as `CLAUDE_CONFIG_DIR`, +`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. There is no +launchd agent; see [INSTALL.md § macOS](../INSTALL.md#macos). ### Who may stop the server diff --git a/openapi.yaml b/openapi.yaml index 73ddbbc9..3fa5a56e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10636,7 +10636,7 @@ paths: tags: - Lanes summary: Find or adopt the lane owning a working directory - description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.' + description: 'Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.' operationId: ensureLane requestBody: required: true diff --git a/scripts/plugin-bootstrap.js b/scripts/plugin-bootstrap.js index 36be1182..5c6d564d 100644 --- a/scripts/plugin-bootstrap.js +++ b/scripts/plugin-bootstrap.js @@ -359,13 +359,15 @@ function cliDir() { * * @returns {"written"|"exists"|"foreign"|"failed"} */ -function linkCli(dir = cliDir(), root = PLUGIN_ROOT) { +function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath) { const marker = "# agenthub-plugin-launcher"; const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub"); const body = process.platform === "win32" ? `@rem agenthub-plugin-launcher\r\n@node "${path.join(root, "bin", "agenthub.js")}" %*\r\n` - : `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`; + : // The pinned node is a versioned path (Homebrew Cellar, nvm) that an + // upgrade deletes; fall back to whatever `node` PATH has then. + `#!/bin/sh\n${marker}\nN="${nodePath}"; [ -x "$N" ] || N=node\nexec "$N" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`; try { if (fs.existsSync(target)) { const current = fs.readFileSync(target, "utf8"); @@ -419,7 +421,11 @@ function liveServers() { try { const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8")); const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed]; - return servers.filter((s) => s && isPidAlive(s.pid)); + // An entry older than this boot is a leftover whose pid may now belong to + // an unrelated process (macOS has no systemd to clean up after a reboot). + // 60 s margin for clock skew; an unparseable startedAt is kept. + const bootedAt = Date.now() - os.uptime() * 1000 - 60_000; + return servers.filter((s) => s && isPidAlive(s.pid) && !(Date.parse(s.startedAt) < bootedAt)); } catch { return []; } diff --git a/scripts/plugin-doctor.js b/scripts/plugin-doctor.js index c51a2a09..a429460f 100644 --- a/scripts/plugin-doctor.js +++ b/scripts/plugin-doctor.js @@ -316,7 +316,9 @@ function cliStatus() { : [ "warn", "agenthub CLI", - `${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`, + `${file} exists but ${dir} is not on PATH — add${ + process.platform === "darwin" ? " to ~/.zshrc" : "" + }: export PATH="${dir}:$PATH"`, ]; } diff --git a/server/__tests__/env-scrub.test.js b/server/__tests__/env-scrub.test.js new file mode 100644 index 00000000..a53eda3c --- /dev/null +++ b/server/__tests__/env-scrub.test.js @@ -0,0 +1,51 @@ +/** + * @file Tests that the server drops the variables describing the Claude Code + * session that started it (always the case on macOS, where there is no + * systemd unit to give it a clean env) — tmux and every lane pane inherit the + * server's env, so a leaked CLAUDECODE made each pane look like a nested + * session. User configuration (CLAUDE_CONFIG_DIR, ANTHROPIC_*, …) must survive. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-envscrub-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); +process.env.CLAUDE_HOME = path.join(TMP, "home"); +process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); + +process.env.CLAUDECODE = "1"; +process.env.CLAUDE_CODE_ENTRYPOINT = "cli"; +process.env.CLAUDE_CODE_SESSION_ID = "abc"; +process.env.CLAUDE_CODE_MESSAGING_SOCKET = "/tmp/x.sock"; +process.env.CLAUDE_PLUGIN_ROOT = "/some/cache/dir"; +process.env.CLAUDE_CONFIG_DIR = "/Users/x/.claude-work"; +process.env.CLAUDE_CODE_USE_BEDROCK = "1"; +process.env.ANTHROPIC_BASE_URL = "https://example.invalid"; + +require("../index"); +const { db } = require("../db"); + +after(() => { + db.close(); + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +it("strips the parent session's variables and keeps user configuration", () => { + for (const k of [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_PLUGIN_ROOT", + ]) { + assert.equal(process.env[k], undefined, k); + } + assert.equal(process.env.CLAUDE_CONFIG_DIR, "/Users/x/.claude-work"); + assert.equal(process.env.CLAUDE_CODE_USE_BEDROCK, "1"); + assert.equal(process.env.ANTHROPIC_BASE_URL, "https://example.invalid"); +}); diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index 75e14fd8..68de84c5 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -1269,3 +1269,21 @@ describe("the forward-only hold is short enough for one work session", () => { assert.equal(result, null); }); }); + +describe("createLane — canonical cwd (macOS /tmp -> /private/tmp class)", () => { + it("stores the realpath of a symlinked dir, and the given string when it does not exist yet", () => { + const fs = require("node:fs"); + const base = fs.realpathSync(fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-realpath-"))); + const target = pathMod.join(base, "real"); + const link = pathMod.join(base, "link"); + fs.mkdirSync(target); + fs.symlinkSync(target, link); + try { + assert.equal(lanes.createLane({ cwd: link + "/" }).cwd, target); + const missing = pathMod.join(base, "not-yet"); + assert.equal(lanes.createLane({ cwd: missing }).cwd, missing); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); +}); diff --git a/server/__tests__/plugin-bootstrap.test.js b/server/__tests__/plugin-bootstrap.test.js index d542a1c0..e4353baf 100644 --- a/server/__tests__/plugin-bootstrap.test.js +++ b/server/__tests__/plugin-bootstrap.test.js @@ -61,6 +61,16 @@ describe("serverIsLive", () => { assert.equal(boot.serverIsLive(), true); assert.deepEqual(boot.liveServers(), [{ port: 4820, pid: process.pid, startedAt: "x" }]); }); + + it("ignores an entry started before this boot, even when its pid is alive (pid reuse)", () => { + const beforeBoot = new Date(Date.now() - os.uptime() * 1000 - 3600_000).toISOString(); + fs.writeFileSync( + infoPath, + JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: beforeBoot }] }) + ); + assert.equal(boot.serverIsLive(), false); + assert.deepEqual(boot.liveServers(), []); + }); }); describe("runtime location", () => { @@ -419,6 +429,26 @@ describe("agenthub CLI launcher", () => { assert.equal(boot.linkCli(bin), "foreign"); assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/); }); + + it( + "falls back to `node` on PATH when the pinned node binary is gone (brew upgrade)", + { skip: process.platform === "win32" }, + () => { + const root = path.join(TMP_HOME, "fake-root"); + fs.mkdirSync(path.join(root, "bin"), { recursive: true }); + fs.writeFileSync( + path.join(root, "bin", "agenthub.js"), + "console.log('ran:' + process.argv.slice(2).join(','));\n" + ); + assert.equal(boot.linkCli(bin, root, "/nonexistent/Cellar/node/0.0.0/bin/node"), "written"); + assert.match(fs.readFileSync(target, "utf8"), /\/nonexistent\/Cellar/); + const out = require("child_process").execFileSync("sh", [target, "a", "b c"], { + encoding: "utf8", + env: { ...process.env, PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` }, + }); + assert.equal(out.trim(), "ran:a,b c"); + } + ); }); describe("plugin-open / plugin-bootstrap require cycle", () => { diff --git a/server/__tests__/session-liveness.test.js b/server/__tests__/session-liveness.test.js index 97f85eeb..d077cbae 100644 --- a/server/__tests__/session-liveness.test.js +++ b/server/__tests__/session-liveness.test.js @@ -393,3 +393,55 @@ describe("watchdog liveness reap", () => { assert.equal(stmts.getSession.get(sid).status, "completed"); }); }); + +describe("probeLiveCwds — darwin branch (ps + lsof, stubbed)", () => { + const realPlatform = process.platform; + const PS = + " 101 /opt/homebrew/bin/claude --resume x\n 102 node /Users/x/claude-dashboard/index.js\n 103 claude\n"; + const LSOF = "p101\nfcwd\nn/Users/x/proj\np103\nfcwd\nn/private/tmp/other\n"; + let calls; + + beforeEach(() => { + delete process.env.DASHBOARD_LIVENESS_PROBE; + process.env.AGENTHUB_FORCE_HOST = "1"; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + calls = []; + }); + after(() => { + Object.defineProperty(process, "platform", { value: realPlatform, configurable: true }); + liveness.__setExecImpl(null); + delete process.env.AGENTHUB_FORCE_HOST; + process.env.DASHBOARD_LIVENESS_PROBE = "0"; + }); + + it("reads each claude pid's cwd from lsof -Fn records", () => { + liveness.__setExecImpl((bin, args) => { + calls.push([bin, args]); + return bin === "ps" ? PS : LSOF; + }); + const r = realProbe(); + assert.equal(r.available, true); + assert.deepEqual([...r.cwds].sort(), ["/Users/x/proj", "/private/tmp/other"]); + assert.deepEqual(calls[1], ["lsof", ["-a", "-p", "101,103", "-d", "cwd", "-Fn"]]); + }); + + it("keeps lsof's partial output when it exits 1 because a pid vanished", () => { + liveness.__setExecImpl((bin) => { + if (bin === "ps") return PS; + const err = new Error("exit 1"); + err.stdout = "p101\nfcwd\nn/Users/x/proj\n"; + throw err; + }); + const r = realProbe(); + assert.equal(r.available, true); + assert.deepEqual([...r.cwds], ["/Users/x/proj"]); + }); + + it("reports unavailable when lsof fails with no output", () => { + liveness.__setExecImpl((bin) => { + if (bin === "ps") return PS; + throw Object.assign(new Error("ENOENT"), { stdout: "" }); + }); + assert.equal(realProbe().available, false); + }); +}); diff --git a/server/index.js b/server/index.js index 42cdf0f0..cf15f57c 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,18 @@ if (!process.env.NODE_ENV) process.env.NODE_ENV = "production"; +// A server started from inside a Claude Code session (the bootstrap fallback, +// always the case on macOS) inherits that session's own variables, and tmux +// hands them to every lane pane — whose `claude` then believes it is nested. +// ponytail: a DENYLIST of parent-session variables only, so user config +// (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) passes through; +// it must grow when Claude Code adds a new per-session variable. +const PARENT_SESSION_VARS = + /^(CLAUDECODE|CLAUDE_PID|CLAUDE_PROJECT_DIR|CLAUDE_ENV_FILE|CLAUDE_PLUGIN_(ROOT|DATA)|CLAUDE_CODE_(ENTRYPOINT|SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|EXECPATH|MESSAGING_.*))$/; +for (const k of Object.keys(process.env)) { + if (PARENT_SESSION_VARS.test(k)) delete process.env[k]; +} + // Load .env file (simple key=value, no external dependency needed) (function loadDotEnv() { const fs = require("fs"); diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 9faa67c0..c752be76 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -160,6 +160,24 @@ function normalizeTitle(value) { return String(value).trim().slice(0, TITLE_MAX); } +/** + * The path the OS calls this directory: macOS hands a session `/private/tmp/x` + * for a typed `/tmp/x`, and a case-insensitive volume accepts `~/Code` for + * `~/code`, so a lane stored under the typed string would never bind a + * session. A path that does not exist yet (a worktree lane is created before + * its directory) keeps the string it was given. + */ +function canonicalCwd(cwd) { + // A relative path must stay relative, so createLane still refuses it. + if (!cwd.startsWith("/")) return cwd; + const trimmed = cwd.replace(/\/+$/, ""); + try { + return require("node:fs").realpathSync.native(trimmed); + } catch { + return trimmed; + } +} + function createLane({ title = "", cwd, @@ -184,7 +202,7 @@ function createLane({ ) .run( normalizeTitle(title), - cwd.replace(/\/+$/, ""), + canonicalCwd(cwd), branch, pipeline, kind, @@ -842,6 +860,7 @@ module.exports = { DEAD_SEC, VALID_TOOLS, detectionIsStale, + canonicalCwd, createLane, listLanes, getLane, diff --git a/server/lib/session-liveness.js b/server/lib/session-liveness.js index e350839a..b6d12aef 100644 --- a/server/lib/session-liveness.js +++ b/server/lib/session-liveness.js @@ -21,6 +21,13 @@ const fs = require("node:fs"); const path = require("node:path"); const { isInsideContainer } = require("../../scripts/install-hooks"); +let execImpl = execFileSync; + +/** Test seam — same pattern as tmux.js / desktop-env.js. */ +function __setExecImpl(fn) { + execImpl = fn || execFileSync; +} + const UNAVAILABLE = () => ({ available: false, cwds: new Set() }); /** @@ -62,7 +69,7 @@ function probeLiveCwds() { let psOut; try { - psOut = execFileSync("ps", ["-Ao", "pid=,args="], { + psOut = execImpl("ps", ["-Ao", "pid=,args="], { encoding: "utf8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024, @@ -95,7 +102,7 @@ function probeLiveCwds() { // machine format emits `p` / `f cwd` / `n` records. let lsofOut; try { - lsofOut = execFileSync("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], { + lsofOut = execImpl("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], { encoding: "utf8", timeout: 10_000, maxBuffer: 16 * 1024 * 1024, @@ -113,4 +120,4 @@ function probeLiveCwds() { return { available: true, cwds }; } -module.exports = { probeLiveCwds, isClaudeCommand }; +module.exports = { probeLiveCwds, isClaudeCommand, __setExecImpl }; diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index ae7cd2d8..bbb4904b 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -116,7 +116,7 @@ const paths = { tags: ["Lanes"], summary: "Find or adopt the lane owning a working directory", description: - "Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.", + "Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.", operationId: "ensureLane", requestBody: { required: true, diff --git a/server/routes/hooks.js b/server/routes/hooks.js index 7817ecd1..f056d1cc 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -1592,6 +1592,13 @@ function livenessReap({ ignoreIdleGate = false } = {}) { continue; } if (probe.cwds.has(resolvedCwd)) continue; + // lsof reports the OS's own path (/private/tmp on macOS); a session row may + // hold the symlinked or differently-cased spelling of the same directory. + try { + if (probe.cwds.has(require("fs").realpathSync.native(resolvedCwd))) continue; + } catch { + /* directory gone — the process cannot be sitting in it either */ + } // Idle gate (watchdog ticks only — boot passes skip it, see above): the // transcript mtime is the ground truth for "when did this session last diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 97f4c495..ae2bbf0b 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -319,7 +319,10 @@ router.post("/ensure", sameOriginGuard, (req, res) => { // specific directory (e.g. Sagi's mascot companion) get a real, existing // path and lane uniqueness stays meaningful. Callers that do care pass one // through unchanged. - const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir(); + // Canonical before the lookup, so the lookup and createLane's insert agree. + const cwd = lanesLib.canonicalCwd( + typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir() + ); if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) { return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });