const BIN = process.env.AUTOFORK_OPENCODE_BIN || "autofork";
const TITLE_PREFIX = "autofork/";
const KEEP_FORK_SESSIONS = !!process.env.AUTOFORK_KEEP_FORK_SESSIONS;
const SWEEP_AGE_MS = 60 * 60 * 1000;
const SPAWN_CTX = "Context for this run: fork '";
const CONTINUE = "<<autofork:continue>>";
const DECORATION = /^[\s`*_~>\-:.!'"()\[\]]*$/;
const INVISIBLE = /[\u00AD\u034F\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
function isSentinelLine(line) {
const t = line.replace(INVISIBLE, "").trim();
const i = t.indexOf(CONTINUE);
if (i < 0) return false;
return DECORATION.test(t.slice(0, i)) && DECORATION.test(t.slice(i + CONTINUE.length));
}
function wantsContinue(text) {
return text.replace(INVISIBLE, "").includes(CONTINUE);
}
const GUARD_MAX_DEPTH = 8;
const GUARD_VIOLATION = [
"operation not permitted",
"eperm",
"permission denied",
"read-only file system",
"erofs",
"network is unreachable",
"could not resolve host",
"temporary failure in name resolution",
"nodename nor servname provided",
"connection refused",
];
function looksLikeViolation(output) {
const o = String(output ?? "").toLowerCase();
return GUARD_VIOLATION.some((f) => o.includes(f));
}
function guardHooks(resolveTarget) {
const sandboxed = new Map();
async function evaluate(payload) {
const proc = Bun.spawn([BIN, "guard", "eval"], {
stdin: new TextEncoder().encode(JSON.stringify(payload)),
stdout: "pipe",
stderr: "ignore",
});
const out = await new Response(proc.stdout).text();
await proc.exited;
const verdict = JSON.parse(out.trim());
if (!verdict?.action) throw new Error(`no verdict in ${JSON.stringify(out.slice(0, 200))}`);
return verdict;
}
return {
"tool.execute.before": async (input, output) => {
const target = await resolveTarget(input?.sessionID);
if (!target) return; let verdict;
try {
verdict = await evaluate({
fork_path: target.fork_path,
...(target.fork ? { fork: target.fork } : {}),
client: "opencode",
tool: input?.tool ?? "",
args: output?.args ?? {},
cwd: target.cwd,
});
} catch (e) {
throw new Error(
`autofork guard could not evaluate this call, so it was refused: ${e?.message ?? e}`,
);
}
if (verdict.action === "deny") {
throw new Error(verdict.message || "autofork guard: this call is not allowed for this fork");
}
if (verdict.action === "sandbox" && verdict.command) {
if (output?.args) output.args.command = verdict.command;
if (input?.callID && verdict.message) {
if (sandboxed.size >= 64) sandboxed.delete(sandboxed.keys().next().value);
sandboxed.set(input.callID, verdict.message);
}
}
},
"tool.execute.after": async (input, output) => {
const id = input?.callID;
if (!id) return;
const message = sandboxed.get(id);
if (message === undefined) return;
sandboxed.delete(id);
if (typeof output?.output === "string" && looksLikeViolation(output.output)) {
output.output += "\n\n" + message;
}
},
};
}
function stripContinue(text) {
if (!wantsContinue(text)) return text;
return text
.split("\n")
.filter((l) => !isSentinelLine(l))
.map((l) => {
const cleaned = l.replace(INVISIBLE, "");
return cleaned.includes(CONTINUE) ? cleaned.split(CONTINUE).join("").trimEnd() : l;
})
.join("\n")
.trimEnd();
}
export const AutoforkPlugin = async ({ client, directory, worktree }) => {
if (process.env.AUTOFORK_FORK || process.env.AUTOFORK_SESSION_ID) {
const forkPath = process.env.AUTOFORK_FORK_PATH;
if (!forkPath) return {};
const target = {
fork_path: forkPath,
fork: process.env.AUTOFORK_FORK_NAME || undefined,
cwd: directory,
};
return guardHooks(() => target);
}
const sessions = new Map();
const forkRuns = new Map();
const liveByFork = new Map();
const reports = new Map();
const ignored = new Set();
const injectTurn = new Set();
const selfPrompt = new Set();
const parked = new Map();
const backoff = new Map();
const reportKey = (parentID, fork) => `${parentID}::${fork}`;
const guardTargets = new Map();
async function guardTargetFor(id) {
if (!id) return null;
if (guardTargets.has(id)) return guardTargets.get(id);
const chain = [];
let target = null;
let cur = id;
for (let depth = 0; depth < GUARD_MAX_DEPTH && cur; depth++) {
const run = forkRuns.get(cur);
if (!run && guardTargets.has(cur)) {
target = guardTargets.get(cur);
break;
}
chain.push(cur);
let info = null;
try {
info = (await client.session.get({ path: { id: cur } }))?.data;
} catch {
return run?.guard && run.path
? { fork_path: run.path, fork: run.fork, cwd: directory }
: null;
}
if (run?.guard && run.path) {
target = { fork_path: run.path, fork: run.fork, cwd: info?.directory || directory };
break;
}
if (!info?.parentID) break;
cur = info.parentID;
}
for (const seen of chain) guardTargets.set(seen, target);
return target;
}
const guard = guardHooks(guardTargetFor);
let modelLimits = null;
async function contextWindow(model) {
if (!model?.providerID || !model.modelID) return undefined;
if (!modelLimits) {
try {
const res = await client.config.providers();
const limits = new Map();
for (const p of res?.data?.providers ?? []) {
for (const [id, m] of Object.entries(p.models ?? {})) {
if (m?.limit?.context) limits.set(`${p.id}/${id}`, m.limit.context);
}
}
if (limits.size === 0) return undefined;
modelLimits = limits;
} catch {
return undefined;
}
}
return modelLimits.get(`${model.providerID}/${model.modelID}`);
}
async function call(kind, payload, marker) {
try {
const proc = Bun.spawn([BIN, "opencode", "hook", kind], {
stdin: new TextEncoder().encode(JSON.stringify({ directory, worktree, ...payload })),
stdout: "pipe",
stderr: "ignore",
});
if (marker) marker.proc = proc;
const out = await new Response(proc.stdout).text();
await proc.exited;
if (!out.trim()) return null;
try {
return JSON.parse(out);
} catch {
return null;
}
} catch {
return null;
}
}
function sessionState(id) {
let s = sessions.get(id);
if (!s) {
s = { started: false, lastStatus: "idle", tokens: null, model: null, agent: null };
sessions.set(id, s);
}
return s;
}
function hasSpawnCtx(msgs) {
for (const m of msgs) {
if (m?.info?.role !== "user") continue;
const text = (m.parts ?? [])
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n");
if (text.includes(SPAWN_CTX)) return true;
}
return false;
}
async function eligible(id) {
if (forkRuns.has(id)) return false;
if (ignored.has(id)) return false;
if (sessions.get(id)?.started) return true;
try {
const res = await client.session.get({ path: { id } });
const info = res?.data;
if (!info) return false; if (info.parentID || info.title?.startsWith(TITLE_PREFIX)) {
ignored.add(id);
return false;
}
} catch {
return false;
}
try {
const msgs = (await client.session.messages({ path: { id } }))?.data ?? [];
if (hasSpawnCtx(msgs)) {
ignored.add(id);
return false;
}
} catch {
}
return true;
}
async function ensureStarted(id) {
const s = sessionState(id);
if (s.started) return;
s.started = true;
await call("session-start", {
session_id: id,
model: s.model?.modelID,
context_window: await contextWindow(s.model),
});
}
async function park(id) {
if (ignored.has(id) || !sessions.has(id)) return;
const s = sessionState(id);
const mode = s.lastStatus === "busy" ? "busy" : "idle";
if (parked.get(id)?.mode === mode) return;
const marker = { mode, proc: null };
parked.set(id, marker);
const startedAt = Date.now();
const res = await call(
"stop-wait",
{
session_id: id,
model: s.model?.modelID,
context_tokens: s.tokens ?? undefined,
context_window: await contextWindow(s.model),
...(mode === "busy" ? { busy: true } : {}),
},
marker,
);
const superseded = parked.get(id) !== marker;
if (!superseded) parked.delete(id);
if (res?.wake?.forks?.length) {
await executeWake(id, res.wake.forks);
}
if (res?.wake?.feed?.blocks?.length) {
await deliverFeed(id, res.wake.feed);
}
if (superseded || parked.has(id)) return;
if (ignored.has(id) || !sessions.has(id)) return;
const b = backoff.get(id) ?? { delay: 1000 };
const longPark = Date.now() - startedAt > 5000;
b.delay = res?.wake || longPark ? 1000 : Math.min(b.delay * 2, 60000);
backoff.set(id, b);
await new Promise((r) => setTimeout(r, b.delay));
if (!parked.has(id)) await park(id);
}
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function nextPartID(prev) {
let rnd = "";
for (let i = 0; i < 14; i++) rnd += BASE62[Math.floor(Math.random() * BASE62.length)];
const m = /^[a-z]+_([0-9a-f]{12})/.exec(prev ?? "");
const time = m
? BigInt("0x" + m[1]) + 1n
: BigInt(Date.now()) * 4096n; return `prt_${time.toString(16).padStart(12, "0").slice(-12)}${rnd}`;
}
async function deliverFeed(parentID, feed) {
if (forkRuns.has(parentID) || ignored.has(parentID)) return;
const text = feed.blocks.join("\n\n");
selfPrompt.add(parentID);
try {
if (feed.wake) {
const parent = sessionState(parentID);
injectTurn.add(parentID);
await client.session.promptAsync({
path: { id: parentID },
body: {
...(parent.model ? { model: parent.model } : {}),
...(parent.agent ? { agent: parent.agent } : {}),
parts: [{ type: "text", text, synthetic: true }],
},
});
} else {
await client.session.prompt({
path: { id: parentID },
body: { noReply: true, parts: [{ type: "text", text, synthetic: true }] },
});
}
} catch {
injectTurn.delete(parentID);
} finally {
selfPrompt.delete(parentID);
}
}
async function executeWake(parentID, forks) {
if (forkRuns.has(parentID) || ignored.has(parentID)) return;
const parent = sessionState(parentID);
for (const spec of forks) {
if (!spec.overlap && (liveByFork.get(spec.name) ?? 0) > 0) continue;
let forkedID = null;
try {
const forked = (await client.session.fork({ path: { id: parentID } }))?.data;
if (!forked?.id) continue;
forkedID = forked.id;
ignored.add(forked.id);
await client.session
.update({
path: { id: forked.id },
body: { title: `${TITLE_PREFIX}${spec.name} (${spec.trigger})` },
})
.catch(() => {});
let prompt = spec.prompt;
for (const pred of spec.after ?? []) {
const r = reports.get(reportKey(parentID, pred));
if (r) {
prompt += `\n\nThis fork runs after '${pred}'; its report follows so you can build on it:\n${r}`;
}
}
forkRuns.set(forked.id, {
parent: parentID,
fork: spec.name,
trigger: spec.trigger,
chain: spec.chain === true,
path: spec.path,
guard: spec.guard ?? null,
done: false,
});
liveByFork.set(spec.name, (liveByFork.get(spec.name) ?? 0) + 1);
await call("fork-spawned", {
session_id: parentID,
fork: spec.name,
run_ref: forked.id,
});
const parseModel = (id) => {
const i = id.indexOf("/");
return i > 0 ? { providerID: id.slice(0, i), modelID: id.slice(i + 1) } : null;
};
const modelCandidates = spec.model
? [spec.model, ...(spec.model_fallbacks ?? [])].map(parseModel).filter(Boolean)
: [parent.model];
if (modelCandidates.length === 0) modelCandidates.push(parent.model);
const runAgent = spec.mode ?? parent.agent;
let prompted = false;
let lastErr = null;
for (const runModel of modelCandidates) {
try {
await client.session.promptAsync({
path: { id: forked.id },
body: {
...(runModel ? { model: runModel } : {}),
...(runAgent ? { agent: runAgent } : {}),
parts: [{ type: "text", text: prompt }],
},
});
prompted = true;
break;
} catch (e) {
lastErr = e;
}
}
if (!prompted) throw lastErr ?? new Error("promptAsync failed on every model candidate");
} catch {
if (forkedID && forkRuns.has(forkedID)) {
forkRuns.delete(forkedID);
liveByFork.set(spec.name, Math.max(0, (liveByFork.get(spec.name) ?? 0) - 1));
await call("fork-completed", {
session_id: parentID,
fork: spec.name,
run_ref: forkedID,
status: "failed",
});
if (!KEEP_FORK_SESSIONS) {
try {
await client.session.delete({ path: { id: forkedID } });
} catch {
}
}
}
}
}
}
async function finishForkRun(id, status) {
const run = forkRuns.get(id);
if (!run || run.done) return;
run.done = true;
forkRuns.delete(id);
liveByFork.set(run.fork, Math.max(0, (liveByFork.get(run.fork) ?? 0) - 1));
let report = "";
try {
const msgs = (await client.session.messages({ path: { id } }))?.data ?? [];
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]?.info?.role === "assistant") {
report = (msgs[i].parts ?? [])
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
.trim();
break;
}
}
} catch {
}
const chainNext = status === "completed" && run.chain && wantsContinue(report);
if (chainNext) report = stripContinue(report);
let discard = false;
if (run.chain && status === "completed") {
const state = await call("run-state", { session_id: run.parent, run_ref: id });
discard = state?.stale === true;
}
if (status === "completed" && report && !discard) {
reports.set(reportKey(run.parent, run.fork), report);
}
const body =
status === "completed"
? report || "(the fork finished without a report)"
: `(the fork run ${status}${report ? `; its last message:\n${report}` : ""})`;
const block = `---\nsource: autofork\nfork: ${run.fork} (trigger: ${run.trigger}) — ${status}\n---\n${body}`;
selfPrompt.add(run.parent);
try {
if (discard) {
} else if (chainNext) {
const parent = sessionState(run.parent);
injectTurn.add(run.parent);
await client.session.promptAsync({
path: { id: run.parent },
body: {
...(parent.model ? { model: parent.model } : {}),
...(parent.agent ? { agent: parent.agent } : {}),
parts: [{ type: "text", text: block, synthetic: true }],
},
});
} else {
await client.session.prompt({
path: { id: run.parent },
body: { noReply: true, parts: [{ type: "text", text: block, synthetic: true }] },
});
}
} catch {
injectTurn.delete(run.parent);
} finally {
selfPrompt.delete(run.parent);
}
await call("fork-completed", {
session_id: run.parent,
fork: run.fork,
run_ref: id,
status,
...(chainNext ? { continue: true } : {}),
});
if (!KEEP_FORK_SESSIONS && status === "completed") {
try {
await client.session.delete({ path: { id } });
} catch {
}
}
}
if (!KEEP_FORK_SESSIONS) {
(async () => {
const cutoff = Date.now() - SWEEP_AGE_MS;
for (;;) {
const page =
(await client.session.list({ query: { search: TITLE_PREFIX, limit: 200 } }))?.data ?? [];
let deleted = 0;
for (const info of page) {
if (info?.parentID || !info?.title?.startsWith(TITLE_PREFIX)) continue;
if ((info.time?.updated ?? Infinity) > cutoff) continue;
if (forkRuns.has(info.id)) continue;
try {
await client.session.delete({ path: { id: info.id } });
deleted++;
} catch {
}
}
if (deleted === 0) break;
}
for (const search of ["Fork of", "(fork #"]) {
const page =
(await client.session.list({ query: { search, limit: 200 } }))?.data ?? [];
for (const info of page) {
if (info?.parentID) continue;
if ((info.time?.updated ?? Infinity) > cutoff) continue;
if (forkRuns.has(info.id)) continue;
try {
const msgs = (await client.session.messages({ path: { id: info.id } }))?.data ?? [];
if (hasSpawnCtx(msgs)) await client.session.delete({ path: { id: info.id } });
} catch {
}
}
}
})().catch(() => {});
}
return {
"tool.execute.before": guard["tool.execute.before"],
"tool.execute.after": guard["tool.execute.after"],
dispose: async () => {
const ends = [];
for (const [id, s] of sessions) {
if (s.started)
ends.push(
call("session-end", { session_id: id, reason: "disposed", bin: process.execPath }),
);
}
await Promise.allSettled(ends);
for (const [, marker] of parked) {
try {
marker.proc?.kill();
} catch {
}
}
parked.clear();
sessions.clear();
},
"chat.message": async (input, output) => {
try {
const id = input?.sessionID;
if (!id || forkRuns.has(id) || ignored.has(id) || selfPrompt.has(id)) return;
if (!(await eligible(id))) return;
const s = sessionState(id);
if (input.model?.providerID && input.model?.modelID) s.model = input.model;
if (input.agent) s.agent = input.agent;
await ensureStarted(id);
const res = await call("message", {
session_id: id,
model: s.model?.modelID,
context_window: await contextWindow(s.model),
});
const blocks = res?.context?.blocks ?? [];
const parts = output?.parts;
if (!blocks.length || !Array.isArray(parts)) return;
const last = parts[parts.length - 1];
parts.push({
id: nextPartID(last?.id),
sessionID: id,
messageID: output.message?.id ?? last?.messageID,
type: "text",
synthetic: true,
text: blocks.join("\n\n"),
});
} catch {
}
},
event: async ({ event }) => {
const type = event?.type;
const props = event?.properties ?? {};
if (type === "session.status") {
const id = props.sessionID;
if (!id) return;
const status = props.status?.type;
if (forkRuns.has(id)) {
if (status === "idle") await finishForkRun(id, "completed");
return;
}
if (!(await eligible(id))) return;
const s = sessionState(id);
const was = s.lastStatus;
s.lastStatus = status === "idle" ? "idle" : "busy";
if (status === "idle") {
await ensureStarted(id);
backoff.delete(id);
await park(id);
} else if (was !== "busy") {
await ensureStarted(id);
const nonWaking = injectTurn.delete(id);
await call("prompt-submit", {
session_id: id,
...(nonWaking ? { waking: false } : {}),
});
backoff.delete(id);
await park(id);
}
return;
}
if (type === "message.updated") {
const info = props.info;
if (info?.role !== "assistant" || !info.sessionID) return;
if (forkRuns.has(info.sessionID) || ignored.has(info.sessionID)) return;
const s = sessionState(info.sessionID);
const t = info.tokens;
if (t) s.tokens = (t.input ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0);
if (info.providerID && info.modelID) {
s.model = { providerID: info.providerID, modelID: info.modelID };
}
if (info.mode) s.agent = info.mode;
return;
}
if (type === "session.error") {
const id = props.sessionID;
if (id && forkRuns.has(id)) await finishForkRun(id, "failed");
return;
}
if (type === "session.deleted") {
const id = props.info?.id;
if (!id) return;
if (forkRuns.has(id)) {
await finishForkRun(id, "stopped");
return;
}
if (sessions.get(id)?.started) {
await call("session-end", { session_id: id, reason: "deleted", bin: process.execPath });
}
sessions.delete(id);
ignored.delete(id);
guardTargets.delete(id);
backoff.delete(id);
injectTurn.delete(id);
return;
}
},
};
};