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:
+48
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
|
||||
+9
-3
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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"`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
+20
-1
@@ -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,
|
||||
|
||||
@@ -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<pid>` / `f cwd` / `n<path>` 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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
Reference in New Issue
Block a user