fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat

- #69: tmux applies a pane's `-c` only while its server's own cwd exists.
  The server was forked from the dashboard's cwd (the plugin cache dir),
  outlives updates, and the old cache dir is pruned — after that every new
  pane started in a deleted directory, where `claude` died at once ("run not
  found") and a shell printed getcwd errors. tmux calls now run from `/`,
  and the pane's sh wrapper cds into its folder itself, which also heals a
  tmux server that is already stuck.
- Changes panel: a workspace's extra folders are sections stacked under the
  workspace folder (VS Code Source Control style), with Close repository /
  Close other repositories on a header's right-click.
- Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]`
  line folds into its turn; uploads under `<data dir>/drops/` are listed.
- Update check: a checkout holding commits a newer release lacks reports
  `diverged_from_release` instead of offering a fast-forward that can only fail.
This commit is contained in:
2026-10-09 17:24:14 +07:00
parent fa156d6a59
commit 96e4d28f7c
25 changed files with 589 additions and 208 deletions
+2 -2
View File
@@ -3100,7 +3100,7 @@ describe("GET /api/sessions/:id/outline", () => {
hook_type: "UserPromptSubmit",
data: {
session_id: sid,
prompt: `compare these ${drop}\n/tmp/agenthub-drops-1000/fedcba9876543210.png`,
prompt: `compare these ${drop}\n/home/u/.claude/agenthub/drops/fedcba9876543210.png`,
},
});
const mem = "/home/u/.claude/projects/-w/memory/release.md";
@@ -3113,7 +3113,7 @@ describe("GET /api/sessions/:id/outline", () => {
await pre("Write", { file_path: mem, content: "x" });
const { body } = await fetch(`/api/sessions/${sid}/outline`);
assert.deepEqual(body.uploads, ["/tmp/agenthub-drops-1000/fedcba9876543210.png", drop]);
assert.deepEqual(body.uploads, ["/home/u/.claude/agenthub/drops/fedcba9876543210.png", drop]);
// a.ts was read, then edited: an output, no longer a file it only used.
assert.deepEqual(body.files, ["/w/src/c.ts"]);
assert.deepEqual(body.memory, [mem]);
+2 -2
View File
@@ -185,8 +185,8 @@ describe("lane-sources instructions and launch", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
// The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js).
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6);
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
+3 -2
View File
@@ -482,10 +482,11 @@ describe("lane start input", () => {
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
assert.equal(created[created.indexOf("-c") + 1], cwd);
// After `--` only the fixed size-wait wrapper that execs the user's own
// default shell (issue #55) — nothing from the request.
// default shell (issue #55) in the lane's folder (#69) — nothing from
// the request.
const argv = created.slice(created.indexOf("--") + 1);
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
assert.equal(argv.length, 3);
assert.deepEqual(argv.slice(3), ["sh", cwd]);
assert.match(argv[2], /exec "\$SHELL" -l$/);
assert.ok(!created.includes("ignored"));
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
+23 -10
View File
@@ -10,6 +10,7 @@ const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
@@ -80,7 +81,7 @@ describe("pty-run", () => {
});
try {
const t0 = Date.now();
const child = spawn("sh", ["-c", argv[2]], {
const child = spawn("sh", ["-c", argv[2], "sh", os.tmpdir()], {
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
});
let out = "";
@@ -168,7 +169,7 @@ describe("pty-run", () => {
const respawn = calls.find((c) => c[0] === "respawn-pane");
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
const argv = respawn.slice(respawn.indexOf("--") + 1);
assert.equal(argv[0], "claude");
assert.deepEqual(argv.slice(3, 6), ["sh", "/tmp/repo", "claude"]);
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
});
@@ -507,21 +508,32 @@ describe("pty-run", () => {
}
/** The CLI's own command line: what tmux passes after the `--`, less the
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
* `sh -c '<cd; wait for a client>; exec "$@"' sh <cwd>` wrapper spawnRun
* puts first. */
function paneArgv(newSessionCall) {
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
assert.match(argv[2], /session_attached.*exec "\$@"$/);
return argv.slice(4);
assert.match(argv[2], /^cd -- "\$1".*session_attached.*exec "\$@"$/);
return argv.slice(5);
}
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
assert.deepEqual(pty.paneArgv(argv, "win32"), argv);
assert.deepEqual(pty.paneArgv(null, "win32"), []);
assert.equal(pty.paneArgv(argv, "linux")[0], "sh");
assert.deepEqual(pty.paneArgv(argv, "linux").slice(-3), argv);
assert.match(pty.paneArgv(null, "linux")[2], /exec "\$SHELL" -l$/);
assert.deepEqual(pty.paneArgv(argv, "C:\\w", "win32"), argv);
assert.deepEqual(pty.paneArgv(null, "C:\\w", "win32"), []);
assert.equal(pty.paneArgv(argv, "/w", "linux")[0], "sh");
assert.deepEqual(pty.paneArgv(argv, "/w", "linux").slice(-3), argv);
assert.match(pty.paneArgv(null, "/w", "linux")[2], /exec "\$SHELL" -l$/);
});
it("starts a pane in its folder even where tmux skips -c (issue #69)", () => {
// A tmux server whose own cwd was deleted (a pruned plugin cache dir)
// ignores `-c` and hands every pane that deleted cwd.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pane-cwd-"));
const argv = pty.paneArgv(["pwd"], dir, "linux");
const out = execFileSync(argv[0], argv.slice(1), { cwd: "/", encoding: "utf8" });
assert.equal(fs.realpathSync(out.trim()), fs.realpathSync(dir));
fs.rmSync(dir, { recursive: true });
});
it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
@@ -578,6 +590,7 @@ describe("pty-run", () => {
[
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
"sh",
os.tmpdir(),
"printf",
"%s|",
"x y",
@@ -291,6 +291,39 @@ describe("getUpdatesStatus — release drives update_available", () => {
assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/);
});
it("does not offer a fast-forward when HEAD holds commits the newer release lacks", async () => {
// The release squashed the local wip commits into a new one: neither side
// contains the other, so `merge --ff-only` could only fail.
const repo = makeVersionedRepo(tmpDir, "diverged", "1.5.0", null);
git(repo, ["checkout", "-b", "rel"]);
fs.writeFileSync(
path.join(repo, "package.json"),
JSON.stringify({ name: "fx", version: "1.6.0" })
);
git(repo, [...IDENT, "commit", "-am", "release"]);
git(repo, ["tag", "v1.6.0"]);
git(repo, ["checkout", "main"]);
fs.writeFileSync(path.join(repo, "wip.txt"), "wip");
git(repo, [...IDENT, "add", "."]);
git(repo, [...IDENT, "commit", "-m", "wip"]);
const status = await getUpdatesStatus(repo, {
skipFetch: true,
latestRelease: {
ok: true,
tag: "v1.6.0",
name: "1.6.0",
url: "u",
published_at: null,
notes: null,
},
});
assert.equal(status.update_available, true);
assert.equal(status.situation, "diverged_from_release");
assert.doesNotMatch(status.manual_command, /--ff-only/);
assert.match(status.situation_note, /v1\.6\.0/);
});
it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => {
const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0");
const status = await getUpdatesStatus(repo, {
+21 -12
View File
@@ -81,20 +81,29 @@ const WAIT_FOR_CLIENT =
const CLIENT_WAIT_MS = 2000;
/**
* A pane's tmux command: `argv` behind {@link WAIT_FOR_CLIENT}, or — with no
* `argv` — the login shell behind it. Windows has no `sh` (a WinGet tmux runs
* native programs), and an `sh -c` there died at once and took the session
* with it, so it gets the command itself, or nothing for tmux's own default
* shell.
* `sh` that moves into `$1` itself. tmux applies `-c` only while its server's
* own cwd still exists: the server is forked from the dashboard's cwd — a
* plugin cache dir, deleted by `pruneRetiredPluginRoots` after an update the
* server outlives — and from then on every pane started in that deleted dir,
* where `claude` dies at once and a shell prints `getcwd` errors (issue #69).
*/
const IN_CWD = 'cd -- "$1" 2>/dev/null; shift;';
/**
* A pane's tmux command, run in `cwd`: `argv` behind {@link WAIT_FOR_CLIENT},
* or — with no `argv` — the login shell behind it. Windows has no `sh` (a
* WinGet tmux runs native programs), and an `sh -c` there died at once and
* took the session with it, so it gets the command itself, or nothing for
* tmux's own default shell.
* ponytail: no wait for the attach on Windows, so a pane may first draw at
* 80×24 (issue #55); port the wait to PowerShell if that shows.
*/
function paneArgv(argv, platform = process.platform) {
function paneArgv(argv, cwd, platform = process.platform) {
if (platform === "win32") return argv || [];
// argv rides as sh's positional parameters, never inside the script text.
// cwd and argv ride as sh's positional parameters, never inside the script text.
return argv
? ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$@"`, "sh", ...argv]
: ["sh", "-c", `${WAIT_FOR_CLIENT} exec "$SHELL" -l`];
? ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$@"`, "sh", cwd, ...argv]
: ["sh", "-c", `${IN_CWD} ${WAIT_FOR_CLIENT} exec "$SHELL" -l`, "sh", cwd];
}
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const CODEX_APPROVALS = new Set(["on-request", "never"]);
@@ -614,7 +623,7 @@ function spawnRun(args) {
if (!tmux.hasSession(id)) {
codexPool?.place();
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
record();
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
@@ -625,7 +634,7 @@ function spawnRun(args) {
// still answered 200. Run the argv in the pane the user already sees
// instead of erroring or opening a second session.
codexPool?.place();
tmux.sendCommand(id, paneCmd, env, cwd);
tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
record();
schedulePaste();
}
@@ -677,7 +686,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
// the login shell tmux would have run (tmux sets SHELL in the pane).
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env: paneEnv });
tmux.newSession({ name: id, cwd, argv: paneArgv(null, cwd), env: paneEnv });
dashboardRuns?.recordRun({
id,
sessionId: null,
+3 -1
View File
@@ -38,7 +38,9 @@ const { execFile, execFileSync } = require("node:child_process");
// try/catch and treats failure as "no session" — piping tmux's own
// "error connecting to ..." noise to the process's stderr just spams the
// journal on every poll while no tmux server is running yet.
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
// cwd "/": a tmux server is forked from the call that starts it and keeps
// that cwd; once it is deleted, tmux ignores every pane's `-c` (issue #69).
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], cwd: "/" };
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
// Linux: the tmux server lives in the cgroup of whatever started it — the
+10
View File
@@ -698,6 +698,16 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
`This dashboard runs from the Claude Code plugin cache (marketplace '${pluginInstall.marketplace}'), ` +
`so it can't update itself. Run the commands above, then '/reload-plugins' in Claude Code — ` +
`or just run '/agenthub-update', which does both and rebuilds the runtime.`;
} else if (release.ok && releaseIsNewer && commitsAheadOfRelease > 0) {
// HEAD carries commits the release lacks (e.g. local wip that the release
// squashed into a new commit): `merge --ff-only` can only fail, and how to
// reconcile is the user's call, so fetch the tag and say why.
situation = "diverged_from_release";
manualParts = [`cd "${root}"`, `git fetch ${canonicalRemote} --tags`];
situationNote =
`Your checkout has ${commitsAheadOfRelease} commit(s) that release ${release.tag} doesn't contain, ` +
`so it can't be fast-forwarded. If they're already in the release, move onto it with ` +
`'git reset --keep ${release.tag}'; otherwise merge or rebase onto ${release.tag}.`;
} else if (release.ok && releaseIsNewer) {
// Move onto the release TAG rather than the branch tip: the tag is what
// was published, and a fast-forward keeps the tracking branch intact (a
+3 -2
View File
@@ -379,9 +379,10 @@ const outlineEvents = db.prepare(
);
// A file the composer attached (`/api/run/drop-*` names it), as its path is
// pasted into the prompt; and a Claude Code memory file.
// The drop dir is `<data dir>/drops/`; older transcripts name `<tmp>/agenthub-drops-<uid>/`.
const UPLOAD_RE =
/(?:^|\s)(\S*\/agenthub-drops-\d+\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
const isUpload = (p) => /\/agenthub-drops-\d+\//.test(p);
/(?:^|\s)(\S*\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
const isUpload = (p) => /\/(?:agenthub-drops-\d+|drops)\/[0-9a-f]{16}/.test(p);
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
/** Records `p` once, newest last. */
const touch = (set, p) => {