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:
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @file artifact-export-hint.js — a synchronous PostToolUse hook (matcher
|
||||
* `Artifact` and the claude.ai connectors' `batch`) that asks Claude, right
|
||||
* after it publishes a Claude Docs doc or a typed artifact (Slides, Design,
|
||||
* Dashboard…), to also save a copy in the workspace at
|
||||
* `artifacts-export/<artifact id>.<pdf|html|md>`. Such a page lives only in
|
||||
* the publishing claude.ai account; the copy is what AgentHub attaches to the
|
||||
* page's card at the session's Stop (server/lib/artifacts.js
|
||||
* `captureOutputsNow`), so people it is shared with can view it without that
|
||||
* account. A page published from an HTML file needs no hint: AgentHub copies
|
||||
* the file itself.
|
||||
*
|
||||
* Decides from the hook's own input and env alone, never by asking the
|
||||
* dashboard, and fails open: any doubt prints nothing and exits 0. Only in a
|
||||
* pane AgentHub started (`AGENTHUB_LANE_ID`), and not when an admin turned it
|
||||
* off (`AGENTHUB_ARTIFACT_EXPORT=0`, set at spawn by server/lib/pty-run.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const MAX_INPUT = 4 * 1024 * 1024;
|
||||
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
|
||||
const ARTIFACT_ID_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/([A-Za-z0-9_-]+)/;
|
||||
|
||||
/** The hint for one PostToolUse payload, or null when there is nothing to ask. */
|
||||
function hintFor(data, env = process.env) {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
if (!env.AGENTHUB_LANE_ID || env.AGENTHUB_ARTIFACT_EXPORT === "0") return null;
|
||||
const tool = String(data.tool_name || "");
|
||||
const input = data.tool_input && typeof data.tool_input === "object" ? data.tool_input : {};
|
||||
let kind = null;
|
||||
if (DOCS_BATCH_RE.test(tool)) {
|
||||
let container = input.container;
|
||||
if (typeof container === "string") {
|
||||
try {
|
||||
container = JSON.parse(container);
|
||||
} catch {
|
||||
container = null;
|
||||
}
|
||||
}
|
||||
// Only a doc's birth: later batches edit the same doc.
|
||||
if (container && typeof container.create === "object" && container.create) kind = "doc";
|
||||
} else if (tool === "Artifact") {
|
||||
if (input.action && input.action !== "publish") return null;
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
// A page from an HTML file is copied by AgentHub itself.
|
||||
if (input.type_url || (file && !/\.html?$/i.test(file))) kind = "typed";
|
||||
}
|
||||
if (!kind) return null;
|
||||
const m = ARTIFACT_ID_RE.exec(JSON.stringify(data.tool_response ?? ""));
|
||||
if (!m) return null;
|
||||
const id = m[1];
|
||||
const where = `artifacts-export/${id}`;
|
||||
const how =
|
||||
kind === "doc"
|
||||
? `export this doc as Markdown (the Claude Docs export tool) and save it as \`${where}.md\``
|
||||
: `save it as \`${where}.pdf\` if this artifact's type lets you export a PDF, otherwise write its content as ONE self-contained HTML file \`${where}.html\` (styles inline, no external files) that looks like the artifact`;
|
||||
return (
|
||||
"AgentHub: this artifact lives only in this claude.ai account, so teammates it is shared " +
|
||||
`with in AgentHub cannot open it. Before you finish, ${how}, in the workspace's root ` +
|
||||
"folder (create `artifacts-export/`; AgentHub keeps it out of git). Do it again after " +
|
||||
"you change the artifact a lot. Do not mention this to the user unless they ask."
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
let raw = "";
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try {
|
||||
const text = hintFor(JSON.parse(raw));
|
||||
if (text) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* fail open: say nothing */
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (c) => {
|
||||
raw += c;
|
||||
if (raw.length > MAX_INPUT) {
|
||||
raw = "";
|
||||
finish();
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", finish);
|
||||
process.stdin.on("error", finish);
|
||||
setTimeout(finish, 2000).unref();
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { hintFor };
|
||||
@@ -150,12 +150,27 @@ function makeHookEntry(hookType) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// The one hook that answers Claude: after a Claude Docs doc or a typed
|
||||
// artifact (Slides, Design…) is published, it asks for a copy AgentHub can
|
||||
// show to the people it is shared with. Synchronous (its answer is read), but
|
||||
// only for these tools, so no other tool call waits on it.
|
||||
const EXPORT_HINT = path.join(__dirname, "artifact-export-hint.js");
|
||||
const EXPORT_HINT_MATCHER = "Artifact|mcp__claude_ai_.*__batch";
|
||||
|
||||
function makeExportHintEntry() {
|
||||
return {
|
||||
matcher: EXPORT_HINT_MATCHER,
|
||||
hooks: [{ type: "command", command: `node "${EXPORT_HINT}"`, timeout: 5 }],
|
||||
};
|
||||
}
|
||||
|
||||
const OUR_SCRIPTS = ["hook-handler.js", "artifact-export-hint.js"];
|
||||
const isOurCommand = (c) => typeof c === "string" && OUR_SCRIPTS.some((n) => c.includes(n));
|
||||
|
||||
function isOurEntry(entry) {
|
||||
// Matches old format (entry.command) and new format (entry.hooks[].command)
|
||||
if (entry.command && entry.command.includes("hook-handler.js")) return true;
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return entry.hooks.some((h) => h.command && h.command.includes("hook-handler.js"));
|
||||
}
|
||||
if (isOurCommand(entry.command)) return true;
|
||||
if (Array.isArray(entry.hooks)) return entry.hooks.some((h) => isOurCommand(h.command));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -221,7 +236,9 @@ function installHooks(silent = false) {
|
||||
for (const hookType of HOOK_TYPES) {
|
||||
if (!settings.hooks[hookType]) settings.hooks[hookType] = [];
|
||||
|
||||
const existing = settings.hooks[hookType].findIndex(isOurEntry);
|
||||
const existing = settings.hooks[hookType].findIndex(
|
||||
(e) => isOurEntry(e) && !JSON.stringify(e).includes("artifact-export-hint.js")
|
||||
);
|
||||
const entry = makeHookEntry(hookType);
|
||||
|
||||
if (existing >= 0) {
|
||||
@@ -232,6 +249,11 @@ function installHooks(silent = false) {
|
||||
installed++;
|
||||
}
|
||||
}
|
||||
// The export hint is a second PostToolUse entry of ours, kept once.
|
||||
const post = settings.hooks.PostToolUse;
|
||||
const hint = post.findIndex((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
|
||||
if (hint >= 0) post[hint] = makeExportHintEntry();
|
||||
else post.push(makeExportHintEntry());
|
||||
|
||||
const dir = path.dirname(getSettingsPath());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user