fix(macos): scrub parent-session env, survive node upgrades and reboots, store lanes by real path (#22)

What broke (code audit, untested on a Mac):
- Without systemd the server is always spawned from inside a Claude Code
  session, so it inherited CLAUDECODE, CLAUDE_CODE_SESSION_ID,
  CLAUDE_PLUGIN_ROOT, ... and tmux handed them to every lane pane (a codex
  pane even resolved as `claude` in `agenthub`'s harness check).
- The ~/.local/bin/agenthub launcher pinned a versioned node path that
  `brew upgrade`/nvm deletes, leaving the CLI dead until the next bootstrap.
- A discovery entry left over from before a reboot could name a recycled pid,
  so the bootstrap's fast path believed a dashboard was live and
  stopDashboard could signal an unrelated process.
- Lanes were stored under the typed cwd; macOS reports /private/tmp and the
  on-disk case, so sessions never bound to a /tmp or differently-cased lane.

What changed:
- server/index.js deletes a DENYLIST of parent-session variables at startup;
  user config (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) stays.
- linkCli's launcher falls back to `node` on PATH when the pinned one is gone.
- liveServers ignores entries whose startedAt predates this boot (60 s margin).
- createLane and POST /api/lanes/ensure store/look up fs.realpathSync.native
  of an existing cwd; the liveness reaper also tries the realpath. Existing
  rows are untouched.
- session-liveness gains an exec seam so the darwin ps+lsof branch is tested.
- plugin-doctor names ~/.zshrc on darwin; INSTALL.md gains a macOS section
  with the Mac verification checklist, mirrored in both READMEs and PLUGINS.md.
No launchd, no macOptionIsMeta, engines stays >=20.
This commit is contained in:
2026-09-23 12:09:28 +07:00
parent aff79a787c
commit 478418f602
18 changed files with 288 additions and 15 deletions
+48
View File
@@ -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 the browser terminal and your local terminal are two clients on one pane, and
restarting the dashboard never stops a run. 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 ## Step 1 — Clone the repository
+6
View File
@@ -104,6 +104,12 @@ sudo pacman -S tmux # Arch
Windows has no native tmux — run AgentHub under WSL2 and install it there. 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: Verify, both from your shell and from the server's point of view:
```bash ```bash
+6
View File
@@ -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 đó. 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: Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
```bash ```bash
+1 -1
View File
@@ -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. 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: To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository:
+9 -3
View File
@@ -128,13 +128,13 @@ check.
milliseconds — the real work happens in a detached worker, so a session never milliseconds — the real work happens in a detached worker, so a session never
waits on an install: 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)). 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). 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. 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. 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. 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. 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 9. **Server** — on Linux with `systemd --user` available, install/refresh a
`agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead `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 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 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 ### Who may stop the server
+1 -1
View File
@@ -10636,7 +10636,7 @@ paths:
tags: tags:
- Lanes - Lanes
summary: Find or adopt the lane owning a working directory 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 operationId: ensureLane
requestBody: requestBody:
required: true required: true
+9 -3
View File
@@ -359,13 +359,15 @@ function cliDir() {
* *
* @returns {"written"|"exists"|"foreign"|"failed"} * @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 marker = "# agenthub-plugin-launcher";
const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub"); const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
const body = const body =
process.platform === "win32" process.platform === "win32"
? `@rem agenthub-plugin-launcher\r\n@node "${path.join(root, "bin", "agenthub.js")}" %*\r\n` ? `@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 { try {
if (fs.existsSync(target)) { if (fs.existsSync(target)) {
const current = fs.readFileSync(target, "utf8"); const current = fs.readFileSync(target, "utf8");
@@ -419,7 +421,11 @@ function liveServers() {
try { try {
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8")); const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed]; 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 { } catch {
return []; return [];
} }
+3 -1
View File
@@ -316,7 +316,9 @@ function cliStatus() {
: [ : [
"warn", "warn",
"agenthub CLI", "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"`,
]; ];
} }
+51
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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");
});
+18
View File
@@ -1269,3 +1269,21 @@ describe("the forward-only hold is short enough for one work session", () => {
assert.equal(result, null); 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 });
}
});
});
+30
View File
@@ -61,6 +61,16 @@ describe("serverIsLive", () => {
assert.equal(boot.serverIsLive(), true); assert.equal(boot.serverIsLive(), true);
assert.deepEqual(boot.liveServers(), [{ port: 4820, pid: process.pid, startedAt: "x" }]); 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", () => { describe("runtime location", () => {
@@ -419,6 +429,26 @@ describe("agenthub CLI launcher", () => {
assert.equal(boot.linkCli(bin), "foreign"); assert.equal(boot.linkCli(bin), "foreign");
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/); 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", () => { describe("plugin-open / plugin-bootstrap require cycle", () => {
+52
View File
@@ -393,3 +393,55 @@ describe("watchdog liveness reap", () => {
assert.equal(stmts.getSession.get(sid).status, "completed"); 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);
});
});
+12
View File
@@ -5,6 +5,18 @@
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production"; 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) // Load .env file (simple key=value, no external dependency needed)
(function loadDotEnv() { (function loadDotEnv() {
const fs = require("fs"); const fs = require("fs");
+20 -1
View File
@@ -160,6 +160,24 @@ function normalizeTitle(value) {
return String(value).trim().slice(0, TITLE_MAX); 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({ function createLane({
title = "", title = "",
cwd, cwd,
@@ -184,7 +202,7 @@ function createLane({
) )
.run( .run(
normalizeTitle(title), normalizeTitle(title),
cwd.replace(/\/+$/, ""), canonicalCwd(cwd),
branch, branch,
pipeline, pipeline,
kind, kind,
@@ -842,6 +860,7 @@ module.exports = {
DEAD_SEC, DEAD_SEC,
VALID_TOOLS, VALID_TOOLS,
detectionIsStale, detectionIsStale,
canonicalCwd,
createLane, createLane,
listLanes, listLanes,
getLane, getLane,
+10 -3
View File
@@ -21,6 +21,13 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { isInsideContainer } = require("../../scripts/install-hooks"); 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() }); const UNAVAILABLE = () => ({ available: false, cwds: new Set() });
/** /**
@@ -62,7 +69,7 @@ function probeLiveCwds() {
let psOut; let psOut;
try { try {
psOut = execFileSync("ps", ["-Ao", "pid=,args="], { psOut = execImpl("ps", ["-Ao", "pid=,args="], {
encoding: "utf8", encoding: "utf8",
timeout: 5_000, timeout: 5_000,
maxBuffer: 16 * 1024 * 1024, maxBuffer: 16 * 1024 * 1024,
@@ -95,7 +102,7 @@ function probeLiveCwds() {
// machine format emits `p<pid>` / `f cwd` / `n<path>` records. // machine format emits `p<pid>` / `f cwd` / `n<path>` records.
let lsofOut; let lsofOut;
try { try {
lsofOut = execFileSync("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], { lsofOut = execImpl("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
encoding: "utf8", encoding: "utf8",
timeout: 10_000, timeout: 10_000,
maxBuffer: 16 * 1024 * 1024, maxBuffer: 16 * 1024 * 1024,
@@ -113,4 +120,4 @@ function probeLiveCwds() {
return { available: true, cwds }; return { available: true, cwds };
} }
module.exports = { probeLiveCwds, isClaudeCommand }; module.exports = { probeLiveCwds, isClaudeCommand, __setExecImpl };
+1 -1
View File
@@ -116,7 +116,7 @@ const paths = {
tags: ["Lanes"], tags: ["Lanes"],
summary: "Find or adopt the lane owning a working directory", summary: "Find or adopt the lane owning a working directory",
description: 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", operationId: "ensureLane",
requestBody: { requestBody: {
required: true, required: true,
+7
View File
@@ -1592,6 +1592,13 @@ function livenessReap({ ignoreIdleGate = false } = {}) {
continue; continue;
} }
if (probe.cwds.has(resolvedCwd)) 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 // Idle gate (watchdog ticks only — boot passes skip it, see above): the
// transcript mtime is the ground truth for "when did this session last // transcript mtime is the ground truth for "when did this session last
+4 -1
View File
@@ -319,7 +319,10 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
// specific directory (e.g. Sagi's mascot companion) get a real, existing // specific directory (e.g. Sagi's mascot companion) get a real, existing
// path and lane uniqueness stays meaningful. Callers that do care pass one // path and lane uniqueness stays meaningful. Callers that do care pass one
// through unchanged. // 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)) { if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });