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
+71 -2
View File
@@ -279,8 +279,8 @@ function stopHostPanes(userId) {
}
}
/** Disable (ending every login) or re-enable a user. Users are never deleted:
* S2 will hang owned rows off their id. */
/** Disable (ending every login) or re-enable a user — the reversible way out;
* `deleteUser` below is the one that removes an account for good. */
function setDisabled(username, disabled) {
const u = requireUser(username);
const linux = require("./linux-accounts");
@@ -298,6 +298,74 @@ function setDisabled(username, disabled) {
if (disabled && hasAccount) linux.provision("disable", u.username);
}
/**
* DELETE a user — the Users page's "Delete user" (an admin, password
* confirmed by the route). In order, so a failure leaves the account usable
* rather than half gone: their logins end and the panes of their workspaces
* stop; under S3 the Linux account `ah-<name>` and its whole home are removed
* (`agenthub-provision delete`; EPROVISION stops here, with no row touched);
* without S3 their own folder under AGENTHUB_USERS_ROOT goes. Then the rows:
* their workspaces' records, prefs, push subscriptions, personal AI Proxy
* profiles and the user. Session history and the audit log stay. Never the
* last enabled admin (ELASTADMIN); the route refuses yourself (ESELF).
* A user made again under the same name gets a NEW, empty Linux account.
*/
function deleteUser(username) {
const u = requireUser(username);
if (u.role === "admin") {
const others = sql(
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
).get(u.id).n;
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
}
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
const pty = require("./pty-run");
for (const lane of lanes) {
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
try {
pty.killRun(pty.runIdForLane(lane.id, slot));
} catch {
/* not running */
}
}
}
const linux = require("./linux-accounts");
if (linux.s3Enabled()) {
// Idempotent: a user with no Linux account (made before S3) or no home
// still passes; anything the helper refuses stops the delete here.
if (linux.LINUX_NAME_RE.test(u.username)) linux.provision("delete", u.username);
} else {
const path = require("node:path");
const fs = require("node:fs");
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (root && /^[a-z][a-z0-9_-]{0,63}$/i.test(u.username)) {
const home = path.join(path.resolve(root), u.username);
// Only a real folder directly in the users root — never a link.
const st = fs.lstatSync(home, { throwIfNoEntry: false });
if (st?.isDirectory()) fs.rmSync(home, { recursive: true, force: true });
}
}
const lanesLib = require("./lanes");
for (const lane of lanes) lanesLib.deleteLane(lane.id);
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
try {
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
} catch {
/* an older database without the column */
}
try {
require("node:fs").rmSync(
require("node:path").join(require("./ai-proxies").proxiesDir(), "users", String(u.id)),
{ recursive: true, force: true }
);
} catch {
/* no personal profiles */
}
sql("DELETE FROM users WHERE id = ?").run(u.id);
return { id: u.id, username: u.username, workspaces: lanes.length };
}
/** Give a workspace (a lane id) to a user — for the ones made before server
* mode, which have no owner and so reach no member. */
function assignWorkspace(laneId, username) {
@@ -603,6 +671,7 @@ module.exports = {
setPassword,
setRole,
setDisabled,
deleteUser,
assignWorkspace,
unassignWorkspace,
ownedWorkspaces,