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
+389 -21
View File
@@ -1,7 +1,8 @@
/**
* @file artifacts.js
* @description The Artifacts hub (docs/plans/artifacts/README.md): what agents
* produce, kept per OWNER and — in server mode — shared to every user.
* produce, kept per OWNER and — in server mode — shared to every user (the
* Hub, `shared = 1`) or to chosen people (`artifact_shares`).
*
* Four kinds:
* - `page` a claude.ai Artifact a session published: only a link, accepted
@@ -24,7 +25,7 @@
* them — and a path from a request only ever picks one of the snapshot's
* own recorded files (`snapshotFile`).
* - Visibility: in local mode every row; in server mode a developer sees
* their own rows plus shared ones, admin and analyst see every row (like
* their own rows plus shared ones (to everyone, or to them by name), admin and analyst see every row (like
* sessions), and only the owner (or an admin) changes or deletes one.
* - Installing a shared skill or plugin is the viewer's click, into the
* VIEWER's own accounts only (`installAccounts`): a member's home under S3
@@ -94,13 +95,23 @@ const installsOffFlag = () => path.join(root(), "installs-off");
// ── Visibility ────────────────────────────────────────────────────────────
/** SQL over alias `a`, one `?` = a user id: the row was shared with that user by name. */
const SHARED_TO_ME =
"EXISTS (SELECT 1 FROM artifact_shares sh WHERE sh.artifact_id = a.id AND sh.user_id = ?)";
const sharedTo = (artifactId, userId) =>
!!sql("SELECT 1 FROM artifact_shares WHERE artifact_id = ? AND user_id = ?").get(
Number(artifactId),
Number(userId)
);
/** `{where, params}` over an `artifacts` alias `a`: the rows this request sees. */
function visibleWhere(req) {
const u = req?.user;
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
return {
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
params: [u.id, u.id],
where: `(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?) OR ${SHARED_TO_ME})`,
params: [u.id, u.id, u.id],
};
}
@@ -111,7 +122,8 @@ const canSee = (req, row) => {
return (
row.owner_id === u.id ||
row.shared === 1 ||
(row.owner_id == null && row.suggested_owner_id === u.id)
(row.owner_id == null && row.suggested_owner_id === u.id) ||
(row.id != null && sharedTo(row.id, u.id))
);
};
@@ -123,7 +135,9 @@ const canChange = (req, row) =>
function canSeeId(req, id) {
return canSee(
req,
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
sql("SELECT id, owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(
Number(id)
)
);
}
@@ -135,6 +149,42 @@ function load(req, id) {
return row;
}
/** `[{id, name}]` of the people `artifactId` is shared with by name. */
function sharedWithOf(artifactId) {
return sql(
`SELECT u.id, COALESCE(NULLIF(u.display_name, ''), u.username) AS name
FROM artifact_shares sh JOIN users u ON u.id = sh.user_id
WHERE sh.artifact_id = ? AND u.disabled = 0 ORDER BY name`
).all(Number(artifactId));
}
/**
* Every recipient of `artifactId` by name that is still an account (the
* broadcast audience, and whether anyone keeps a Hub-only row alive). A
* disabled one keeps their row (enabling them again restores it) but is not
* listed to the owner, so it never blocks an edit of the list.
*/
function shareRecipients(artifactId) {
return sql(
`SELECT sh.user_id FROM artifact_shares sh JOIN users u ON u.id = sh.user_id
WHERE sh.artifact_id = ?`
)
.all(Number(artifactId))
.map((r) => r.user_id);
}
/**
* The people a user may share with: every enabled account but themselves,
* name only (server mode). Nothing else about them is returned.
*/
function people(req) {
if (!isServer() || !req?.user) return [];
return sql(
`SELECT id, username, COALESCE(NULLIF(display_name, ''), username) AS name
FROM users WHERE disabled = 0 AND id != ? ORDER BY name COLLATE NOCASE`
).all(req.user.id);
}
function view(row, req, owners = null) {
if (!row) return null;
let owner = null;
@@ -165,6 +215,10 @@ function view(row, req, owners = null) {
size: row.size,
shared: row.shared === 1,
sharedAt: row.shared_at,
// The people it is shared with by name: only its owner (or an admin) sees
// the list; anyone else learns only that it reached them.
sharedWith: canChange(req, row) ? sharedWithOf(row.id) : [],
sharedWithMe: !!req?.user && row.owner_id !== req.user.id && sharedTo(row.id, req.user.id),
inLibrary: row.in_library !== 0,
installs: row.installs,
auto: row.auto === 1,
@@ -194,11 +248,16 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
// Their own library, plus what others shared that they installed (kept
// below only when `installed` — a disk read, so it cannot be SQL).
where.push(
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
`((a.owner_id = ? AND a.in_library = 1) OR ((a.shared = 1 OR ${SHARED_TO_ME}) AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))`
);
params.push(req.user.id, req.user.id);
params.push(req.user.id, req.user.id, req.user.id);
} else if (scope === "hub") {
where.push("a.shared = 1");
} else if (scope === "withme") {
// Shared with the caller by name (server mode).
if (!isServer() || !req?.user) return [];
where.push(SHARED_TO_ME, "a.owner_id IS NOT ?");
params.push(req.user.id, req.user.id);
} else if (scope === "unowned") {
if (!isServer() || !req?.user) return [];
where.push("a.owner_id IS NULL");
@@ -432,9 +491,15 @@ function captureFromHook(sessionId, data, peer = null) {
url,
ownerId
);
if (same) return same;
const file = typeof input.file_path === "string" ? input.file_path : "";
return insert({
// A publish of a page from a file: keep a copy anyone it is shared with can
// view here, without a claude.ai login (a republish takes the new version).
const copy = laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files) : null;
if (same) {
if (copy) schedulePageSnapshot(same.id, copy);
return same;
}
const row = insert({
kind: "page",
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
description: cleanDescription(input.description),
@@ -445,6 +510,186 @@ function captureFromHook(sessionId, data, peer = null) {
url,
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
});
if (copy) schedulePageSnapshot(row.id, copy);
return row;
}
// ── A page's own copy ─────────────────────────────────────────────────────
// A claude.ai page is private to the account that published it, so a viewer
// without it saw nothing. A page published FROM a file keeps a snapshot of
// that file (and the files it names) — viewed here in the sandbox, like a
// pinned HTML file. A Docs doc or a typed artifact (Slides, Design) has no
// such file: Claude is asked (scripts/artifact-export-hint.js) to export a
// copy to `artifacts-export/<artifact id>.(md|pdf)`, which the Stop capture
// attaches to the row instead (`captureOutputsNow`).
const PAGE_FILE_EXT = /\.html?$/i;
const PAGE_FILES_MAX = 50;
const PUBLISHED_RE =
/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._ ()@+-][A-Za-z0-9._ ()@+/-]{0,299}$/;
/** Where Claude is asked to export a typed artifact's copy. */
const EXPORT_DIR = "artifacts-export";
/**
* What to copy for a page: `{laneId, main, extra: [{rel, as}]}` with every
* path workspace-relative, or null when the main file is not an HTML file
* inside the workspace. `cwd` is the session's folder (a relative path in
* the hook is relative to it); `files` is the Artifact tool's supporting
* files map (`{published: source | {from}}`) or list (`[{path}]`).
*/
function pageCopy(laneId, cwd, file, files) {
const lane = require("./lanes").getLane(Number(laneId));
if (!lane || !PAGE_FILE_EXT.test(file)) return null;
const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd;
const inLane = (p) => {
const rel = path.relative(lane.cwd, path.resolve(base, p));
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("/")
: null;
};
const main = inLane(file);
if (!main) return null;
const extra = [];
const add = (as, src) => {
if (typeof as !== "string" || !PUBLISHED_RE.test(as) || typeof src !== "string") return;
const rel = inLane(src);
if (rel && extra.length < PAGE_FILES_MAX) extra.push({ rel, as });
};
if (Array.isArray(files)) {
for (const f of files) add(f?.path, f?.path);
} else if (files && typeof files === "object") {
for (const [as, v] of Object.entries(files)) {
add(as, typeof v === "string" ? v : v && typeof v.from === "string" ? v.from : null);
}
}
return { laneId: lane.id, main, extra };
}
/**
* Copy several workspace files into ONE new snapshot: `entries` =
* `[{rel, as}]`, each read under the same guards as a pinned file and
* written at its published path `as`. A file that is a secret, gone or
* outside the workspace is skipped; the total stays under FILE_MAX_BYTES.
* Returns `{snap, files: [{path, size}]}`, or null when nothing was copied.
*/
async function snapshotLaneFiles(lane, entries) {
const { openDownload } = require("./lane-file-transfer");
const snap = newSnapshotDir();
const files = [];
let total = 0;
try {
for (const { rel, as } of entries) {
// A key or a login is never copied, under either name.
if (
SECRET_NAME_RE.test(path.posix.basename(rel)) ||
SECRET_NAME_RE.test(path.posix.basename(as))
)
continue;
let file;
try {
file = await openDownload(lane.cwd, rel);
} catch {
continue;
}
if (total + file.size > FILE_MAX_BYTES || files.some((f) => f.path === as)) {
file.stream.destroy();
continue;
}
const dest = path.join(snap.dir, ...as.split("/"));
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
await new Promise((resolve, reject) => {
const out = fs.createWriteStream(dest, { mode: 0o600 });
file.stream.on("error", reject);
out.on("error", reject);
out.on("finish", resolve);
file.stream.pipe(out);
});
total += file.size;
files.push({ path: as, size: file.size });
}
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
if (!files.length) {
dropSnapshot(snap.id);
return null;
}
return { snap, files, size: total };
}
/** Put a new snapshot on a row, dropping the one it replaces. */
function setSnapshot(rowId, shot) {
const old = sql("SELECT snapshot FROM artifacts WHERE id = ?").get(rowId);
sql(
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(shot.snap.id, JSON.stringify(shot.files), shot.size, rowId);
if (old?.snapshot && old.snapshot !== shot.snap.id) dropSnapshot(old.snapshot);
}
/** Snapshot a page's copy; the main file goes first, under its own name. */
async function snapshotPage(rowId, copy) {
const lane = require("./lanes").getLane(Number(copy.laneId));
if (!lane) return null;
const mainAs = path.posix.basename(copy.main);
const shot = await snapshotLaneFiles(lane, [
{ rel: copy.main, as: mainAs },
...copy.extra.filter((e) => e.as !== mainAs),
]);
if (!shot || shot.files[0].path !== mainAs) {
if (shot) dropSnapshot(shot.snap.id);
return null;
}
if (!sql("SELECT 1 FROM artifacts WHERE id = ?").get(rowId)) {
dropSnapshot(shot.snap.id); // deleted meanwhile
return null;
}
setSnapshot(rowId, shot);
return sql("SELECT * FROM artifacts WHERE id = ?").get(rowId);
}
// One copy at a time per row: a republish waits for the copy before it.
const pageCopies = new Map();
function schedulePageSnapshot(rowId, copy) {
const prev = pageCopies.get(rowId) || Promise.resolve();
const run = prev
.catch(() => null)
.then(() => snapshotPage(rowId, copy))
.catch((err) => {
console.warn(`[artifacts] page #${rowId} not copied: ${err.message}`);
return null;
});
pageCopies.set(rowId, run);
run.finally(() => {
if (pageCopies.get(rowId) === run) pageCopies.delete(rowId);
});
return run;
}
/**
* A page captured before pages kept a copy: take it now from the file it was
* published from, if that file is still there (once per row per process).
* Any viewer may ask; what they get is the copy, never the workspace file.
*/
const backfillTried = new Set();
async function ensurePageCopy(req, id) {
const row = load(req, id);
if (row.kind !== "page" || row.snapshot || !row.source || row.lane_id == null) {
return view(row, req);
}
if (backfillTried.has(row.id)) return view(row, req);
backfillTried.add(row.id);
const copy = pageCopy(row.lane_id, null, row.source, null);
const next = copy ? await schedulePageSnapshot(row.id, copy) : null;
return view(next || row, req);
}
/** The artifact id a claude.ai page link ends in (`…/artifact/<id>`). */
function pageIdOf(url) {
const m = /\/artifact\/([A-Za-z0-9_-]+)/.exec(String(url || ""));
return m ? m[1] : null;
}
// ── file ──────────────────────────────────────────────────────────────────
@@ -549,6 +794,8 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
/** A typed artifact's exported copy: `artifacts-export/<artifact id>.<ext>`. */
const EXPORT_RE = /^artifacts-export\/([A-Za-z0-9_-]+)\.(md|markdown|pdf|html?|png)$/i;
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
const OUTPUT_MAX = 20;
/** Folders that hold a site's own pages, never a report someone reads. */
@@ -596,10 +843,25 @@ async function outputCandidates(lane, since) {
} else {
paths = (await gd.walkFolder(lane.cwd)).files;
}
// The export folder is read directly: once AgentHub has excluded it from
// git, `ls-files --others --exclude-standard` no longer lists it.
try {
for (const name of await fs.promises.readdir(path.join(lane.cwd, EXPORT_DIR))) {
paths.push(`${EXPORT_DIR}/${name}`);
}
} catch {
/* no export folder */
}
const out = [];
for (const rel of new Set(paths)) {
if (!OUTPUT_EXT.test(rel)) continue;
if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
const exported = EXPORT_RE.test(rel);
if (!OUTPUT_EXT.test(rel) && !exported) continue;
if (
!exported &&
/\.html?$/i.test(rel) &&
(tracked.has(rel) || (await isSitePage(lane.cwd, rel)))
)
continue;
let st;
try {
st = await fs.promises.lstat(path.join(lane.cwd, rel));
@@ -638,6 +900,31 @@ async function captureOutputsNow(laneId, sessionId) {
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
const done = [];
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
const exp = EXPORT_RE.exec(rel);
if (exp) {
// An exported copy of a page of this workspace: it becomes that page's
// copy (no second card). One naming no page of ours is left alone.
const page = sql(
"SELECT * FROM artifacts WHERE kind = 'page' AND lane_id = ? AND url LIKE ? ORDER BY id DESC"
)
.all(lane.id, `%/artifact/${exp[1]}%`)
.find((r) => pageIdOf(r.url) === exp[1]);
if (!page) continue;
const kept = page.files ? JSON.parse(page.files)[0] : null;
if (kept && kept.mtime === mtimeMs && kept.path === path.posix.basename(rel)) continue;
try {
const shot = await snapshotLaneFiles(lane, [{ rel, as: path.posix.basename(rel) }]);
if (!shot) continue;
shot.files[0].mtime = mtimeMs;
setSnapshot(page.id, shot);
// AgentHub's folder, not the user's work: kept out of their git status.
require("./git-exclude").excludeFromGit(lane.cwd, [`${EXPORT_DIR}/`]);
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(page.id));
} catch {
/* a secret name, a vanished file */
}
continue;
}
const rows = sql(
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
).all(lane.id, rel);
@@ -1543,7 +1830,12 @@ function update(req, id, body = {}) {
if (body.skill && typeof body.skill === "object") {
editSkill(req, row, body.skill);
row = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
if (!("title" in body) && !("shared" in body) && !("description" in body))
if (
!("title" in body) &&
!("shared" in body) &&
!("sharedWith" in body) &&
!("description" in body)
)
return view(row, req);
}
const sets = [];
@@ -1576,22 +1868,77 @@ function update(req, id, body = {}) {
params.push(body.shared ? 1 : 0, body.shared ? new Date().toISOString() : null);
}
}
if (!sets.length) return view(row, req);
if (shareChange === "unshared" && row.in_library === 0) {
let people = null;
if ("sharedWith" in body) {
if (!isServer()) throw fail("ELOCALMODE", "sharing needs server mode");
people = cleanPeople(body.sharedWith, row.owner_id);
}
if (!sets.length && !people) return view(row, req);
const nowShared = "shared" in body ? body.shared : row.shared === 1;
const keepsReaders = nowShared || (people ? people.length > 0 : shareRecipients(row.id).length);
if (row.in_library === 0 && !keepsReaders) {
// Off the Hub and out of its owner's library: nothing keeps it.
deleteRow(row);
audit(req, row, "unshared");
return { ...view(row, req), shared: false, removed: true };
}
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
let peopleChange = null;
require("../db").db.transaction(() => {
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
if (people) peopleChange = setSharedWith(row.id, people);
})();
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
if (shareChange) audit(req, next, shareChange);
if (peopleChange) audit(req, next, peopleChange);
return view(next, req);
}
const MAX_PEOPLE = 100;
/** A `sharedWith` body value → distinct ids of enabled users, never the owner. */
function cleanPeople(v, ownerId) {
if (!Array.isArray(v) || v.length > MAX_PEOPLE) {
throw fail("EBADFIELD", `sharedWith: up to ${MAX_PEOPLE} user ids`);
}
const ids = [...new Set(v)];
if (!ids.every((n) => Number.isInteger(n) && n > 0)) {
throw fail("EBADFIELD", "sharedWith: user ids");
}
const known = new Set(
ids.length
? sql(`SELECT id FROM users WHERE disabled = 0 AND id IN (${ids.map(() => "?").join(",")})`)
.all(...ids)
.map((r) => r.id)
: []
);
const bad = ids.find((n) => !known.has(n));
if (bad != null) throw fail("EBADFIELD", `sharedWith: no such user ${bad}`);
return ids.filter((n) => n !== ownerId);
}
/** Replace who `artifactId` is shared with by name; the audit line, or null if unchanged. */
function setSharedWith(artifactId, ids) {
const before = new Set(shareRecipients(artifactId));
const added = ids.filter((n) => !before.has(n));
const removed = [...before].filter((n) => !ids.includes(n));
if (!added.length && !removed.length) return null;
const del = sql("DELETE FROM artifact_shares WHERE artifact_id = ? AND user_id = ?");
for (const n of removed) del.run(artifactId, n);
const ins = sql("INSERT OR IGNORE INTO artifact_shares (artifact_id, user_id) VALUES (?, ?)");
for (const n of added) ins.run(artifactId, n);
const name = (n) => sql("SELECT username FROM users WHERE id = ?").get(n)?.username || `#${n}`;
return [
added.length ? `shared with ${added.map(name).join(", ")}` : "",
removed.length ? `unshared from ${removed.map(name).join(", ")}` : "",
]
.filter(Boolean)
.join("; ");
}
function deleteRow(row) {
sql("DELETE FROM artifact_installs WHERE artifact_id = ?").run(row.id);
sql("DELETE FROM artifact_shares WHERE artifact_id = ?").run(row.id);
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
dropSnapshot(row.snapshot);
}
@@ -1606,14 +1953,15 @@ function remove(req, id) {
const row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
const owner = !req?.user || row.owner_id === req.user.id;
if (owner && row.shared === 1 && row.in_library !== 0) {
const readers = row.shared === 1 || shareRecipients(row.id).length > 0;
if (owner && readers && row.in_library !== 0) {
sql(
"UPDATE artifacts SET in_library = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(row.id);
return { ...row, in_library: 0, kept: true };
}
deleteRow(row);
if (row.shared === 1) audit(req, row, "deleted");
if (readers) audit(req, row, "deleted");
return row;
}
@@ -1703,6 +2051,16 @@ function snapshotFile(req, id, rel) {
const installsEnabled = () => !fs.existsSync(installsOffFlag());
/** Ask Claude to export a copy of a Doc / Slides / Design (the pane env decides). */
const exportsOffFlag = () => path.join(root(), "exports-off");
const exportsEnabled = () => !fs.existsSync(exportsOffFlag());
function setExportsEnabled(on) {
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
if (on) fs.rmSync(exportsOffFlag(), { force: true });
else fs.writeFileSync(exportsOffFlag(), "", { mode: 0o600 });
return exportsEnabled();
}
function setInstallsEnabled(on) {
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
if (on) fs.rmSync(installsOffFlag(), { force: true });
@@ -2137,13 +2495,16 @@ function reshareSkill(req, id) {
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
function shareNow(req, id, changed) {
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
if (isServer() && row.shared !== 1) {
// A row shared with chosen people stays with them: Share again only
// takes the new version, it never widens who sees it.
const byName = shareRecipients(id).length > 0;
if (isServer() && row.shared !== 1 && !byName) {
sql(
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(id);
audit(req, row, "shared");
} else if (changed && row.shared === 1) {
} else if (changed && (row.shared === 1 || byName)) {
audit(req, row, "updated");
}
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
@@ -2430,12 +2791,19 @@ module.exports = {
addPlugin,
update,
remove,
people,
shareRecipients,
snapshotFile,
syncSkillSources,
install,
uninstall,
installsEnabled,
setInstallsEnabled,
exportsEnabled,
setExportsEnabled,
ensurePageCopy,
pageIdOf,
__pageCopies: () => [...pageCopies.values()],
__setExecImpl,
__setInspectImpl,
};