chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes

Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
This commit is contained in:
2026-11-04 15:16:49 +07:00
parent 3daf7ed4cb
commit c946b73d11
95 changed files with 7918 additions and 340 deletions
+39 -8
View File
@@ -21,7 +21,8 @@
* stash would shift), and nothing drops one except a clean restore or a
* Delete with `confirm: true`. They are repo-wide, so repo root only
* (ESUBDIR), never during a merge/cherry-pick/revert/rebase (EMERGING), and
* a restore needs a clean tree (EDIRTY). A conflicted restore keeps its
* a restore needs an empty index and none of its own files changed (EDIRTY;
* changes elsewhere stay). A conflicted restore keeps its
* shelf and leaves the unmerged files to `resolve`. A write that moves
* files under an agent the route reports as working asks for
* `confirm: true` first (EAGENTRUNNING). A merge is the
@@ -617,11 +618,9 @@ async function runGitAction(dir, action, body = {}, opts = {}) {
return { ...done(action, await dropShelf(dir, entry.sha)), sha: entry.sha };
}
const entry = await findShelf(dir, body.sha);
// A restore never mixes with new work: on a clean tree its result is
// exactly the shelf, and a conflict can only be between it and HEAD.
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
const before = await assertRestorable(dir, entry);
assertAgentConfirmed(opts, body, "restoring a shelf");
return applyShelf(dir, entry);
return applyShelf(dir, entry, before);
}
if (action === "ignore") return ignorePaths(dir, body);
@@ -914,13 +913,44 @@ async function dropShelf(dir, sha) {
}
/**
* Put a shelf back on a clean tree. `--index` first, so what was staged comes
* Put a shelf back onto a tree `assertRestorable` let through. `--index` first, so what was staged comes
* back staged; when only the index cannot be rebuilt git refuses before it
* touches the tree, and the shelf is applied without it. A clean apply drops
* the entry; a conflicted one KEEPS it and leaves the unmerged files to the
* `resolve` flow (ours = the branch, theirs = the shelf).
*/
async function applyShelf(dir, entry) {
/**
* A restore never mixes with new work in the files it touches: nothing may be
* staged (`stash apply --index` against a changed index stops halfway, its
* untracked files already written) and no change may be in one of the shelf's
* own files. Changes elsewhere stay where they are — a partial Shelve (n)
* leaves the unticked files behind, and refusing them made such a shelf
* impossible to restore. So a conflict is still only between the shelf and
* HEAD. Answers the tree's status before the restore, for `applyShelf`.
*/
async function assertRestorable(dir, entry) {
const status = await laneStatus(dir);
if (status.some((e) => e.code[0] !== " " && e.code[0] !== "?"))
throw bad("EDIRTY", "the index has staged changes: commit or unstage them first");
const mine = new Set(entry.files.map((f) => f.path));
const overlap = status
.flatMap((e) => [e.path, e.from].filter(Boolean))
.filter((p) => mine.has(p));
if (overlap.length)
throw Object.assign(
bad("EDIRTY", `files in this shelf have changed since: ${overlap.join(", ")}`),
{ paths: [...new Set(overlap)] }
);
return status;
}
const statusKey = (status) =>
status
.map((e) => `${e.code}\0${e.path}\0${e.from || ""}`)
.sort()
.join("\n");
async function applyShelf(dir, entry, before = []) {
const kept = (e, conflicts) => ({
...done("unshelve", e.git || { stdout: "", stderr: "" }),
sha: entry.sha,
@@ -933,7 +963,8 @@ async function applyShelf(dir, entry) {
} catch (e) {
const conflicts = await unmergedPaths(dir);
if (conflicts.length) return kept(e, conflicts);
if (await isDirty(dir)) throw e;
// Only when git refused before touching the tree is a retry safe.
if (statusKey(await laneStatus(dir)) !== statusKey(before)) throw e;
try {
result = await git(dir, ["stash", "apply", entry.sha]);
} catch (e2) {