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:
@@ -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]);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user