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);
}
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 }) => {
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}`;
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;
}
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 ?? [];
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]?.info?.role !== "user") continue;
const text = (msgs[i].parts ?? [])
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n");
if (text.includes(SPAWN_CTX)) {
ignored.add(id);
return false;
}
break;
}
} 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 }],
},
});
} else {
await client.session.prompt({
path: { id: parentID },
body: { noReply: true, parts: [{ type: "text", text }] },
});
}
} 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,
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);
if (status === "completed" && report) {
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 (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 }],
},
});
} else {
await client.session.prompt({
path: { id: run.parent },
body: { noReply: true, parts: [{ type: "text", text: block }] },
});
}
} 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;
}
const page =
(await client.session.list({ query: { search: "Fork of", 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 ?? [];
let isOurs = false;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i]?.info?.role !== "user") continue;
const text = (msgs[i].parts ?? [])
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n");
isOurs = text.includes(SPAWN_CTX);
break;
}
if (isOurs) await client.session.delete({ path: { id: info.id } });
} catch {
}
}
})().catch(() => {});
}
return {
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);
backoff.delete(id);
injectTurn.delete(id);
return;
}
},
};
};