feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt

- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+73 -2
View File
@@ -192,6 +192,54 @@ describe("POST /api/lanes/clone", () => {
});
});
describe("POST /api/lanes/:id/sources/clone", () => {
it("clones beside the workspace folder and adds it as an extra folder", async () => {
const home = path.join(WORK, "app");
fs.mkdirSync(home);
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
assert.ok([200, 201].includes(made.status), JSON.stringify(made.body));
const id = made.body.lane.id;
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
url: "https://forge.test/o/r.git",
name: "lib",
// Ignored: the clone always lands beside the workspace folder.
parent: path.join(ROOT, "elsewhere"),
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const target = path.join(WORK, "lib");
assert.equal(fs.realpathSync(r.body.path), fs.realpathSync(target));
assert.equal(fs.readFileSync(path.join(target, "README.md"), "utf8"), "hi\n");
const folders = r.body.sources.filter((s) => s.kind === "folder");
assert.deepEqual(
folders.map((f) => fs.realpathSync(f.path)),
[fs.realpathSync(target)]
);
const again = await request("POST", `/api/lanes/${id}/sources/clone`, {
url: "https://forge.test/o/r.git",
name: "lib",
});
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "EEXISTS");
const login = await request("POST", `/api/lanes/${id}/sources/clone`, {
url: "https://private.test/o/r.git",
name: "private-lib",
});
assert.equal(login.status, 409);
assert.equal(login.body.error.code, "ECREDENTIALS");
assert.equal(login.body.error.auth.host, "private.test");
});
it("answers 404 for a workspace that does not exist", async () => {
const r = await request("POST", "/api/lanes/99999/sources/clone", {
url: "https://forge.test/o/r.git",
});
assert.equal(r.status, 404);
});
});
describe("/api/me/git", () => {
it("makes a key once, returns only its public half, and deletes it", async () => {
await request("DELETE", "/api/me/git/ssh-key");
@@ -219,6 +267,29 @@ describe("/api/me/git", () => {
assert.equal(del.body.ssh, null);
});
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
await request("POST", "/api/me/git/ssh-key", {});
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
assert.equal(rsa.body.ssh.type, "rsa");
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
encoding: "utf8",
});
assert.match(bits, /^4096 .*\(RSA\)/);
assert.equal(
(await request("POST", "/api/me/git/ssh-key", { replace: true, type: "dsa" })).status,
201
);
// An unknown type falls back to the default (the route only passes rsa through).
assert.equal((await request("GET", "/api/me/git")).body.ssh.type, "ed25519");
await request("DELETE", "/api/me/git/ssh-key");
});
it("reads and sets the commit author, refusing a bad one", async () => {
const r0 = await request("GET", "/api/me/git");
assert.deepEqual(r0.body.author, { name: null, email: null });
@@ -282,10 +353,10 @@ describe("git-identity, member account (S3, stubbed)", () => {
}
});
it("keeps the key and the logins in the member's own home", () => {
it("keeps the key and the logins in the member's own home", async () => {
const account = identity.accountForDir(path.join(HOME, "proj"));
assert.equal(account.kind, "member");
identity.generateSshKey(account);
await identity.generateSshKey(account);
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });