mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
2f8111da6d
Add a shared hooks library for pending subagent launch records and per-agent JSON state. The module filters stale launch records, claims the oldest matching launch under a file lock, and writes agent state with atomic rename semantics.
Verified: bun test tests/subagent-state.test.ts
Verified: node --input-type=module -e "import('./hooks/subagent-state.mjs').then((mod) => { if (typeof mod.listPendingLaunches !== 'function' || typeof mod.claimPendingLaunch !== 'function' || typeof mod.appendPendingLaunch !== 'function' || typeof mod.readAgentState !== 'function' || typeof mod.writeAgentState !== 'function') { process.exit(1); } })"
Swarm-Agent: codex-subagent-state
180 lines
5.9 KiB
JavaScript
180 lines
5.9 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
appendFileSync,
|
|
closeSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from "node:fs";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { dirname, join, resolve, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createLogger, logCaughtError } from "./logger.mjs";
|
|
const log = createLogger();
|
|
function pluginRoot(metaUrl) {
|
|
const base = metaUrl ?? import.meta.url;
|
|
return resolve(dirname(fileURLToPath(base)), "..");
|
|
}
|
|
function requireEnvFile() {
|
|
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
if (!envFile) {
|
|
process.exit(0);
|
|
}
|
|
return envFile;
|
|
}
|
|
function resolveAuditLogPath(hookInputCwd) {
|
|
const cwdFromHookInput = typeof hookInputCwd === "string" && hookInputCwd.trim() !== "" ? hookInputCwd : null;
|
|
const projectRoot = process.env.CLAUDE_PROJECT_ROOT || cwdFromHookInput || process.cwd();
|
|
const configuredPath = process.env.VERCEL_PLUGIN_AUDIT_LOG_FILE;
|
|
if (configuredPath === "off") {
|
|
return null;
|
|
}
|
|
if (typeof configuredPath === "string" && configuredPath.trim() !== "") {
|
|
return resolve(projectRoot, configuredPath);
|
|
}
|
|
const projectSlug = projectRoot.replaceAll("/", "-");
|
|
return join(homedir(), ".claude", "projects", projectSlug, "vercel-plugin", "skill-injections.jsonl");
|
|
}
|
|
function appendAuditLog(record, hookInputCwd) {
|
|
const auditLogPath = resolveAuditLogPath(hookInputCwd);
|
|
if (auditLogPath === null) return;
|
|
try {
|
|
mkdirSync(dirname(auditLogPath), { recursive: true });
|
|
const payload = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...record };
|
|
appendFileSync(auditLogPath, `${JSON.stringify(payload)}
|
|
`, "utf-8");
|
|
} catch (error) {
|
|
logCaughtError(log, "hook-env:append-audit-log-failed", error, { auditLogPath });
|
|
}
|
|
}
|
|
function getDedupScopeId(payload) {
|
|
if (payload && typeof payload === "object" && "agent_id" in payload && typeof payload.agent_id === "string" && payload.agent_id.length > 0) {
|
|
return payload.agent_id;
|
|
}
|
|
return "main";
|
|
}
|
|
const SAFE_SESSION_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
function dedupSessionIdSegment(sessionId) {
|
|
if (SAFE_SESSION_ID_RE.test(sessionId)) {
|
|
return sessionId;
|
|
}
|
|
return createHash("sha256").update(sessionId).digest("hex");
|
|
}
|
|
function dedupScopeIdSegment(scopeId) {
|
|
if (SAFE_SESSION_ID_RE.test(scopeId)) {
|
|
return scopeId;
|
|
}
|
|
return createHash("sha256").update(scopeId).digest("hex");
|
|
}
|
|
function resolveDedupTempPath(sessionId, basename, scopeId) {
|
|
const tempRoot = resolve(tmpdir());
|
|
const scopeSegment = scopeId ? `-${dedupScopeIdSegment(scopeId)}` : "";
|
|
const candidate = resolve(join(tempRoot, `vercel-plugin-${dedupSessionIdSegment(sessionId)}${scopeSegment}-${basename}`));
|
|
const tempPrefix = tempRoot.endsWith(sep) ? tempRoot : `${tempRoot}${sep}`;
|
|
if (!candidate.startsWith(tempPrefix)) {
|
|
throw new Error(`dedup temp path escaped tmpdir: tempRoot=${tempRoot} candidate=${candidate}`);
|
|
}
|
|
return candidate;
|
|
}
|
|
function dedupFilePath(sessionId, kind, scopeId) {
|
|
return resolveDedupTempPath(sessionId, `${kind}.txt`, scopeId);
|
|
}
|
|
function dedupClaimDirPath(sessionId, kind, scopeId) {
|
|
return resolveDedupTempPath(sessionId, `${kind}.d`, scopeId);
|
|
}
|
|
function readSessionFile(sessionId, kind, scopeId) {
|
|
try {
|
|
return readFileSync(dedupFilePath(sessionId, kind, scopeId), "utf-8");
|
|
} catch (error) {
|
|
logCaughtError(log, "hook-env:read-session-file-failed", error, { sessionId, kind, scopeId });
|
|
return "";
|
|
}
|
|
}
|
|
function writeSessionFile(sessionId, kind, value, scopeId) {
|
|
try {
|
|
writeFileSync(dedupFilePath(sessionId, kind, scopeId), value, "utf-8");
|
|
} catch (error) {
|
|
logCaughtError(log, "hook-env:write-session-file-failed", error, { sessionId, kind, scopeId });
|
|
}
|
|
}
|
|
function tryClaimSessionKey(sessionId, kind, key, scopeId) {
|
|
try {
|
|
const claimDir = dedupClaimDirPath(sessionId, kind, scopeId);
|
|
mkdirSync(claimDir, { recursive: true });
|
|
const file = join(claimDir, encodeURIComponent(key));
|
|
const fd = openSync(file, "wx");
|
|
closeSync(fd);
|
|
return true;
|
|
} catch (error) {
|
|
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
function listSessionKeys(sessionId, kind, scopeId) {
|
|
try {
|
|
return readdirSync(dedupClaimDirPath(sessionId, kind, scopeId)).map((entry) => decodeURIComponent(entry)).filter((entry) => entry !== "").sort();
|
|
} catch (error) {
|
|
logCaughtError(log, "hook-env:list-session-keys-failed", error, { sessionId, kind, scopeId });
|
|
return [];
|
|
}
|
|
}
|
|
function syncSessionFileFromClaims(sessionId, kind, scopeId) {
|
|
const value = listSessionKeys(sessionId, kind, scopeId).join(",");
|
|
writeSessionFile(sessionId, kind, value, scopeId);
|
|
return value;
|
|
}
|
|
function removeSessionClaimDir(sessionId, kind, scopeId) {
|
|
try {
|
|
rmSync(dedupClaimDirPath(sessionId, kind, scopeId), { recursive: true, force: true });
|
|
} catch (error) {
|
|
logCaughtError(log, "hook-env:remove-session-claim-dir-failed", error, { sessionId, kind, scopeId });
|
|
}
|
|
}
|
|
function profileCachePath(sessionId) {
|
|
return resolveDedupTempPath(sessionId, "profile.json");
|
|
}
|
|
function generateVerificationId() {
|
|
return randomUUID();
|
|
}
|
|
function safeReadFile(path) {
|
|
try {
|
|
return readFileSync(path, "utf-8");
|
|
} catch (error) {
|
|
logCaughtError(log, "hook-env:safe-read-file-failed", error, { path });
|
|
return null;
|
|
}
|
|
}
|
|
function safeReadJson(path) {
|
|
const content = safeReadFile(path);
|
|
if (content === null) return null;
|
|
try {
|
|
return JSON.parse(content);
|
|
} catch (error) {
|
|
logCaughtError(log, "hook-env:safe-read-json-failed", error, { path });
|
|
return null;
|
|
}
|
|
}
|
|
export {
|
|
appendAuditLog,
|
|
dedupClaimDirPath,
|
|
dedupFilePath,
|
|
generateVerificationId,
|
|
getDedupScopeId,
|
|
listSessionKeys,
|
|
pluginRoot,
|
|
profileCachePath,
|
|
readSessionFile,
|
|
removeSessionClaimDir,
|
|
requireEnvFile,
|
|
safeReadFile,
|
|
safeReadJson,
|
|
syncSessionFileFromClaims,
|
|
tryClaimSessionKey,
|
|
writeSessionFile
|
|
};
|