- validateRemoteUrl checks the literal scheme prefix git will receive and refuses %/backslash in the authority (WHATWG normalises https:/host and percent-escapes that git reads differently); route test for EBADURL. - Parent-session scrub also drops AI_AGENT, and GIT_EDITOR when it is Claude Code's `true`. - canonicalCwd resolves a not-yet-existing path through its deepest existing ancestor, so a symlinked parent still yields one lane. - Note the clock-step ceiling on liveServers' boot-time filter. - Docs/header wording: the Active runs count is lane-scoped.
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
*
|
*
|
||||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||||
* "Active runs" counts everything actually running. They carry no tmux session
|
* "Active runs" counts everything actually running in scope. They carry no tmux session
|
||||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||||
* from them for the same reason.
|
* from them for the same reason.
|
||||||
|
|||||||
+2
-2
@@ -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). 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.
|
**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 is resolved through its deepest existing ancestor (`/tmp/new` → `/private/tmp/new`); 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:
|
||||||
|
|
||||||
@@ -438,7 +438,7 @@ The **Active runs** button in the console header opens the merged run list. It s
|
|||||||
|
|
||||||
1. live in-memory tmux runs from `GET /api/run`,
|
1. live in-memory tmux runs from `GET /api/run`,
|
||||||
2. persisted dashboard runs from `GET /api/run/history`,
|
2. persisted dashboard runs from `GET /api/run/history`,
|
||||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes them, so two hand-started agents read as "2 active runs".
|
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes the ones its lane owns (next paragraph), so two hand-started agents in the lane's folder read as "2 active runs".
|
||||||
|
|
||||||
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
||||||
|
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ function liveServers() {
|
|||||||
// An entry older than this boot is a leftover whose pid may now belong to
|
// 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).
|
// an unrelated process (macOS has no systemd to clean up after a reboot).
|
||||||
// 60 s margin for clock skew; an unparseable startedAt is kept.
|
// 60 s margin for clock skew; an unparseable startedAt is kept.
|
||||||
|
// ponytail: an NTP step larger than 60 s after boot misreads a live entry
|
||||||
|
// as stale; compare against a monotonic boot id if that ever bites.
|
||||||
const bootedAt = Date.now() - os.uptime() * 1000 - 60_000;
|
const bootedAt = Date.now() - os.uptime() * 1000 - 60_000;
|
||||||
return servers.filter((s) => s && isPidAlive(s.pid) && !(Date.parse(s.startedAt) < bootedAt));
|
return servers.filter((s) => s && isPidAlive(s.pid) && !(Date.parse(s.startedAt) < bootedAt));
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -735,6 +735,15 @@ describe("an unborn repo and `publish`", () => {
|
|||||||
"https://host/r .git",
|
"https://host/r .git",
|
||||||
"https://host/r.git\n",
|
"https://host/r.git\n",
|
||||||
"ssh://-oProxyCommand=x/r.git",
|
"ssh://-oProxyCommand=x/r.git",
|
||||||
|
// WHATWG parses these as https://host/x, but git sees no "://" and reads
|
||||||
|
// them as scp-like ssh to a host named "https" — the checked string and
|
||||||
|
// the used string must be the same string.
|
||||||
|
"https:/host/r.git",
|
||||||
|
"https:host/r.git",
|
||||||
|
"https:\\\\host\\r.git",
|
||||||
|
// Percent-encoded userinfo/host that git decodes into a leading "-".
|
||||||
|
"ssh://%2DoProxyCommand=x@host/r.git",
|
||||||
|
"ssh://git@%2Dhost/r.git",
|
||||||
"-git@host:r.git",
|
"-git@host:r.git",
|
||||||
"",
|
"",
|
||||||
42,
|
42,
|
||||||
|
|||||||
@@ -921,6 +921,13 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
|||||||
assert.equal(bogus.body.error.code, "EBADACTION");
|
assert.equal(bogus.body.error.code, "EBADACTION");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
|
||||||
|
const r = await request("POST", `/api/lanes/${laneId}/git/publish`, { url: "file:///tmp/x" });
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "EBADURL");
|
||||||
|
assert.equal(g(dir, "remote").trim(), "");
|
||||||
|
});
|
||||||
|
|
||||||
it("reads the log, one commit's diff, a whole file, the path list and the tree", async () => {
|
it("reads the log, one commit's diff, a whole file, the path list and the tree", async () => {
|
||||||
const log = await request("GET", `/api/lanes/${laneId}/git/log?limit=5`);
|
const log = await request("GET", `/api/lanes/${laneId}/git/log?limit=5`);
|
||||||
assert.equal(log.status, 200);
|
assert.equal(log.status, 200);
|
||||||
@@ -1442,6 +1449,23 @@ describe("/api/lanes tool tests", () => {
|
|||||||
assert.equal(second.body.lane.id, first.body.lane.id);
|
assert.equal(second.body.lane.id, first.body.lane.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /api/lanes/ensure resolves a symlinked path, existing or not, to one lane", async () => {
|
||||||
|
const real = fs.mkdtempSync(path.join(os.tmpdir(), "lane-real-"));
|
||||||
|
const link = `${real}-link`;
|
||||||
|
fs.symlinkSync(real, link);
|
||||||
|
try {
|
||||||
|
for (const sub of ["", "/not-yet"]) {
|
||||||
|
const first = await request("POST", "/api/lanes/ensure", { cwd: real + sub });
|
||||||
|
const second = await request("POST", "/api/lanes/ensure", { cwd: link + sub });
|
||||||
|
assert.equal(second.body.created, false, sub);
|
||||||
|
assert.equal(second.body.lane.id, first.body.lane.id, sub);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(link);
|
||||||
|
fs.rmSync(real, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => {
|
it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => {
|
||||||
const first = await request("POST", "/api/lanes/ensure", {
|
const first = await request("POST", "/api/lanes/ensure", {
|
||||||
cwd: "/tmp/same-tool-lane",
|
cwd: "/tmp/same-tool-lane",
|
||||||
|
|||||||
+4
-1
@@ -12,10 +12,13 @@ if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
|||||||
// (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) passes through;
|
// (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) passes through;
|
||||||
// it must grow when Claude Code adds a new per-session variable.
|
// it must grow when Claude Code adds a new per-session variable.
|
||||||
const PARENT_SESSION_VARS =
|
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_.*))$/;
|
/^(CLAUDECODE|AI_AGENT|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)) {
|
for (const k of Object.keys(process.env)) {
|
||||||
if (PARENT_SESSION_VARS.test(k)) delete process.env[k];
|
if (PARENT_SESSION_VARS.test(k)) delete process.env[k];
|
||||||
}
|
}
|
||||||
|
// Claude Code sets GIT_EDITOR=true so its own commits never block; a user's
|
||||||
|
// real editor setting is anything else and stays.
|
||||||
|
if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
|
||||||
|
|
||||||
// Load .env file (simple key=value, no external dependency needed)
|
// Load .env file (simple key=value, no external dependency needed)
|
||||||
(function loadDotEnv() {
|
(function loadDotEnv() {
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ function validateRemoteUrl(url) {
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
if (/[\s\x00-\x1f\x7f]/.test(url) || url.startsWith("-")) throw refuse();
|
if (/[\s\x00-\x1f\x7f]/.test(url) || url.startsWith("-")) throw refuse();
|
||||||
if (SCP_LIKE.test(url)) return url;
|
if (SCP_LIKE.test(url)) return url;
|
||||||
|
// WHATWG is more forgiving than git: it reads `https:host/x` or `https:\\host`
|
||||||
|
// as https://host/x, where git sees no "://" and dials an ssh host named
|
||||||
|
// "https". So the scheme must be spelled out literally, and the authority
|
||||||
|
// may carry no `%` (git decodes `%2D` into the leading "-" refused below)
|
||||||
|
// and no backslash — the string checked is the string git gets.
|
||||||
|
const scheme = ["https://", "ssh://"].find((p) => url.startsWith(p));
|
||||||
|
if (!scheme || /[%\\]/.test(url.slice(scheme.length).split("/")[0])) throw refuse();
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(url);
|
parsed = new URL(url);
|
||||||
|
|||||||
+11
-4
@@ -171,10 +171,17 @@ function canonicalCwd(cwd) {
|
|||||||
// A relative path must stay relative, so createLane still refuses it.
|
// A relative path must stay relative, so createLane still refuses it.
|
||||||
if (!cwd.startsWith("/")) return cwd;
|
if (!cwd.startsWith("/")) return cwd;
|
||||||
const trimmed = cwd.replace(/\/+$/, "");
|
const trimmed = cwd.replace(/\/+$/, "");
|
||||||
try {
|
// A path that does not exist yet resolves through its deepest existing
|
||||||
return require("node:fs").realpathSync.native(trimmed);
|
// ancestor, so /link/new and /real/new still name the same lane.
|
||||||
} catch {
|
const path = require("node:path");
|
||||||
return trimmed;
|
const tail = [];
|
||||||
|
for (let dir = trimmed; ; dir = path.dirname(dir)) {
|
||||||
|
try {
|
||||||
|
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail);
|
||||||
|
} catch {
|
||||||
|
if (dir === "/" || !dir) return trimmed;
|
||||||
|
tail.unshift(path.basename(dir));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user