From 9dddcd9ef70ef4c431b79ac15d725808854f28e4 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 15:16:35 -0700 Subject: [PATCH] fix(T700): OpenCode v2 places the resume note and never drops it on a receipt OpenCode 2.x drives the plugin default `setup(ctx)` path and marks a turn at `session.execution.started` before the first tool. The busy-turn guard then treated every tool as mid-turn, so a revisit notice could only fall back; at the turn's end the plugin admitted it with `resume:false`, whose msg_ receipt can arrive without a session_message (T544), and discarded the pending copy. - Track tools-per-turn so the first tool of a turn places the note even when the v2 start event already marked the turn. - Drop a kept note only after it actually lands in a tool result. A stop no longer admits it through a receipt; `resume:true` is not used because starting a turn on an idle TUI is not the job of a resume note. - Keep a fresh conversation's start note on its existing queued admission, which the host materialises as the conversation begins. - Add v2 harness tests for both failures; update the two T187 hook tests that encoded the old receipt-retry contract. Checks: the two v2 tests fail on the pre-fix template and pass after it; opencode-resume, opencode-hooks, opencode-no-duplicate-loop, opencode-poller-wake, the full opencode-* suite, tsc --noEmit, npm run build. --- CHANGELOG.md | 2 + dist/setup.js | 38 ++++++++---- src/setup.ts | 38 ++++++++---- test/opencode-hooks.test.ts | 29 +++++---- test/opencode-resume.test.ts | 117 +++++++++++++++++++++++++++++++++-- 5 files changed, 182 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c79b4eb..3b8ac6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **An OpenCode v2 resume note reaches the model instead of being silently dropped (T700).** OpenCode 2.x drives the plugin's default `setup(ctx)` path. It marks a turn at `session.execution.started`, before the first tool, so the busy-turn guard treated every tool as mid-turn and the revisit notice could only fall back; at the turn's end the plugin admitted it with `resume:false`, whose `msg_` receipt can arrive without any `session_message` (T544), and then discarded the pending copy. The first tool of a turn now places the note even after the v2 start event, and a kept note is only dropped after it actually lands in a tool result: a stop no longer acknowledges a receipt as delivery, and `resume:true` is not used because starting a turn on an idle TUI is not a resume note's job. A fresh conversation's start note keeps its queued admission, which the host materialises as the conversation begins. + ## 0.5.48 (2026-10-17) - **Claude SessionStart fork keeps the parent row, and a live holder is named without a takeover suggestion (T185).** A `source: fork` start skips bind and the parent-row delete and prints only the mailbox-context fallback. A session whose name is held live by another session prints that `held by live` reason and does not suggest takeover. diff --git a/dist/setup.js b/dist/setup.js index 664417a..20715f7 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -1504,9 +1504,12 @@ const watchWake = (ctx: NoteCtx, sid: string): void => { /** T642: a session is mid-turn from its first tool call (or execution start) until its turn ends (idle / execution succeeded). * Delivery "queue" holds a note or a wake until that end, so the post-tool notice rides the tool result instead. */ const turnSince = new Map(); +/** T700: tools this turn has already run. The first tool of a turn may place a resume note even on the v2 path, + * where session.execution.started marks the turn before any tool runs; later tools in that same turn are mid-turn. */ +const turnTools = new Map(); const TURN_STALE_MS = 30 * 60_000; -const markTurn = (sid: string): void => { if (!turnSince.has(sid)) turnSince.set(sid, Date.now()); }; -const endTurn = (sid: string): void => { turnSince.delete(sid); }; +const markTurn = (sid: string): void => { if (!turnSince.has(sid)) { turnSince.set(sid, Date.now()); turnTools.set(sid, 0); } }; +const endTurn = (sid: string): void => { turnSince.delete(sid); turnTools.delete(sid); }; /** T675: the turn ended. Tell the daemon (receipt id "idle"), so its next normal pass may wake this session for mail still * unhandled instead of waiting out the busy backoff. It admits nothing. True only when the daemon took it, which means the * wake path owns the post-turn alert for this session; false keeps the stop hook as the fallback. */ @@ -1582,16 +1585,17 @@ const postToolSteady = (sid: string): boolean => { /** One hook call: the JSON payload goes in on stdin; stdout is captured; a reason we recognize is * admitted through the hosting serve's session API. A failure never surfaces. The same call starts * the standalone wake poll once the session id is known. */ -const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record, pending: Map, sending: Set, +const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record, pending: Map, resumeTold: Set, place?: (text: string) => boolean): Promise => { try { const sid = typeof payload.session_id === "string" ? payload.session_id : null; - // Captured before this hook marks or ends the turn, so the first tool of a turn can still admit - // and a turn that is already running cannot. + // Captured before this hook marks or ends the turn. T700: the v2 event loop marks the turn at + // session.execution.started, before its first tool, so "turn open" is not the same as "a tool already ran". const since = sid ? turnSince.get(sid) : undefined; const turnOpen = since !== undefined && Date.now() - since < TURN_STALE_MS; + const firstTool = event === "post-tool" && (turnTools.get(sid ?? "") ?? 0) === 0; if (sid) watchWake(ctx, sid); - if (sid && event === "post-tool") markTurn(sid); + if (sid && event === "post-tool") { markTurn(sid); turnTools.set(sid, (turnTools.get(sid) ?? 0) + 1); } if (sid && event === "stop") endTurn(sid); if (sid && (event === "post-tool" || event === "stop")) await releaseStartNote(ctx, sid, event, place); // T187: retry an unadmitted notice on an existing hook, without bypassing the zero-spawn fast path. @@ -1603,17 +1607,25 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record< const line = "[mbx] Resume note was not admitted. Call mbx_whoami, then mbx_inbox. Mail is unchanged."; if (!(place && place(line))) logWake("resume", session, "not admitted; mail unchanged"); }; + let placed: string | null = null; const deliver = async () => { - if (!sid || sending.has(sid)) return; + if (!sid) return; const text = pending.get(sid); if (!text) return; + // T700: the first tool of a turn is where a resume notice belongs, even when the v2 path marked the turn + // at execution.started. A successful placement is the only proof that the note reached the model, so only + // then is the pending copy dropped. + if (text === placed) { pending.delete(sid); return; } + if (event === "post-tool" && firstTool && place && place(text)) { placed = text; if (pending.get(sid) === text) pending.delete(sid); return; } if (turnOpen && event !== "stop") { sayResumeFallback(sid, text); return; } - if (event === "post-tool" && place && place(text)) { if (pending.get(sid) === text) pending.delete(sid); return; } + // T700: a note kept across a turn is never acknowledged-and-dropped on a receipt. resume:false can return a + // msg_ id without writing a session_message (T544), and resume:true would start a turn on an idle TUI, so the + // next turn's first tool places it. A fresh conversation's start note keeps its queued admission, which the + // host materialises as the conversation begins; only that event may admit without a tool result. + if (event !== "session-start") return; const api = typeof ctx?.session?.synthetic === "function" || typeof ctx?.session?.prompt === "function"; if (!api) { sayResumeFallback(sid, text); return; } - sending.add(sid); - try { if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); } - finally { sending.delete(sid); } + if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); }; await deliver(); // T675: with this session's wake poll live, the daemon's wake is the one post-turn alert: it starts a turn and offers only @@ -1638,8 +1650,8 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record< }; const runFor = (ctx: NoteCtx, bin: string[], cwd: string | undefined) => { - const pending = new Map(), sending = new Set(), resumeTold = new Set(); - return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, resumeTold, place); + const pending = new Map(), resumeTold = new Set(); + return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, resumeTold, place); }; const sidOf = (v: unknown): string | undefined => { diff --git a/src/setup.ts b/src/setup.ts index c62efcd..c45be2c 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1337,9 +1337,12 @@ const watchWake = (ctx: NoteCtx, sid: string): void => { /** T642: a session is mid-turn from its first tool call (or execution start) until its turn ends (idle / execution succeeded). * Delivery "queue" holds a note or a wake until that end, so the post-tool notice rides the tool result instead. */ const turnSince = new Map(); +/** T700: tools this turn has already run. The first tool of a turn may place a resume note even on the v2 path, + * where session.execution.started marks the turn before any tool runs; later tools in that same turn are mid-turn. */ +const turnTools = new Map(); const TURN_STALE_MS = 30 * 60_000; -const markTurn = (sid: string): void => { if (!turnSince.has(sid)) turnSince.set(sid, Date.now()); }; -const endTurn = (sid: string): void => { turnSince.delete(sid); }; +const markTurn = (sid: string): void => { if (!turnSince.has(sid)) { turnSince.set(sid, Date.now()); turnTools.set(sid, 0); } }; +const endTurn = (sid: string): void => { turnSince.delete(sid); turnTools.delete(sid); }; /** T675: the turn ended. Tell the daemon (receipt id "idle"), so its next normal pass may wake this session for mail still * unhandled instead of waiting out the busy backoff. It admits nothing. True only when the daemon took it, which means the * wake path owns the post-turn alert for this session; false keeps the stop hook as the fallback. */ @@ -1415,16 +1418,17 @@ const postToolSteady = (sid: string): boolean => { /** One hook call: the JSON payload goes in on stdin; stdout is captured; a reason we recognize is * admitted through the hosting serve's session API. A failure never surfaces. The same call starts * the standalone wake poll once the session id is known. */ -const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record, pending: Map, sending: Set, +const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record, pending: Map, resumeTold: Set, place?: (text: string) => boolean): Promise => { try { const sid = typeof payload.session_id === "string" ? payload.session_id : null; - // Captured before this hook marks or ends the turn, so the first tool of a turn can still admit - // and a turn that is already running cannot. + // Captured before this hook marks or ends the turn. T700: the v2 event loop marks the turn at + // session.execution.started, before its first tool, so "turn open" is not the same as "a tool already ran". const since = sid ? turnSince.get(sid) : undefined; const turnOpen = since !== undefined && Date.now() - since < TURN_STALE_MS; + const firstTool = event === "post-tool" && (turnTools.get(sid ?? "") ?? 0) === 0; if (sid) watchWake(ctx, sid); - if (sid && event === "post-tool") markTurn(sid); + if (sid && event === "post-tool") { markTurn(sid); turnTools.set(sid, (turnTools.get(sid) ?? 0) + 1); } if (sid && event === "stop") endTurn(sid); if (sid && (event === "post-tool" || event === "stop")) await releaseStartNote(ctx, sid, event, place); // T187: retry an unadmitted notice on an existing hook, without bypassing the zero-spawn fast path. @@ -1436,17 +1440,25 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record< const line = "[mbx] Resume note was not admitted. Call mbx_whoami, then mbx_inbox. Mail is unchanged."; if (!(place && place(line))) logWake("resume", session, "not admitted; mail unchanged"); }; + let placed: string | null = null; const deliver = async () => { - if (!sid || sending.has(sid)) return; + if (!sid) return; const text = pending.get(sid); if (!text) return; + // T700: the first tool of a turn is where a resume notice belongs, even when the v2 path marked the turn + // at execution.started. A successful placement is the only proof that the note reached the model, so only + // then is the pending copy dropped. + if (text === placed) { pending.delete(sid); return; } + if (event === "post-tool" && firstTool && place && place(text)) { placed = text; if (pending.get(sid) === text) pending.delete(sid); return; } if (turnOpen && event !== "stop") { sayResumeFallback(sid, text); return; } - if (event === "post-tool" && place && place(text)) { if (pending.get(sid) === text) pending.delete(sid); return; } + // T700: a note kept across a turn is never acknowledged-and-dropped on a receipt. resume:false can return a + // msg_ id without writing a session_message (T544), and resume:true would start a turn on an idle TUI, so the + // next turn's first tool places it. A fresh conversation's start note keeps its queued admission, which the + // host materialises as the conversation begins; only that event may admit without a tool result. + if (event !== "session-start") return; const api = typeof ctx?.session?.synthetic === "function" || typeof ctx?.session?.prompt === "function"; if (!api) { sayResumeFallback(sid, text); return; } - sending.add(sid); - try { if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); } - finally { sending.delete(sid); } + if (await inject(ctx, sid, "resume-entry", text) && pending.get(sid) === text) pending.delete(sid); }; await deliver(); // T675: with this session's wake poll live, the daemon's wake is the one post-turn alert: it starts a turn and offers only @@ -1471,8 +1483,8 @@ const call = async (ctx: NoteCtx, bin: string[], event: string, payload: Record< }; const runFor = (ctx: NoteCtx, bin: string[], cwd: string | undefined) => { - const pending = new Map(), sending = new Set(), resumeTold = new Set(); - return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, sending, resumeTold, place); + const pending = new Map(), resumeTold = new Set(); + return (event: string, payload: Record, place?: (text: string) => boolean) => call(ctx, bin, event, { cwd, ...payload }, pending, resumeTold, place); }; const sidOf = (v: unknown): string | undefined => { diff --git a/test/opencode-hooks.test.ts b/test/opencode-hooks.test.ts index 8ef3d86..9345086 100644 --- a/test/opencode-hooks.test.ts +++ b/test/opencode-hooks.test.ts @@ -244,16 +244,19 @@ test("T187: failed admission retries on the next hook without another node spawn try { const hooks = await mod.AgentMBXHooks({ directory: "/work", session: { synthetic: async (body: Note) => { notes.push(body); if (!ready) throw new Error("not admitted"); return body; - } } }) as { event: (event: unknown) => Promise; "tool.execute.after": (event: unknown) => Promise }; + } } }) as { event: (event: unknown) => Promise; "tool.execute.after": (event: unknown, output?: { output?: string }) => Promise }; await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_retry" } } } }); assert.equal(notes.length, 1); ready = true; await hooks["tool.execute.after"]({ sessionID: "ses_sibling" }); assert.equal(notes.length, 1, "sibling never receives or drains another entry"); - await hooks["tool.execute.after"]({ sessionID: "ses_retry" }); - assert.equal(notes.length, 2); assert.equal(notes[0].id, notes[1].id); - assert.ok(notes.every(note => note.sessionID === "ses_retry" && note.resume === false)); - await hooks["tool.execute.after"]({ sessionID: "ses_retry" }); - assert.equal(notes.length, 2, "admitted entry is removed from pending"); + // T700: a failed admission is not retried through the session API. The kept note rides the next turn's first + // tool result, which is the only admission the model can be proven to have seen. + const first = { output: "tool ok" }; + await hooks["tool.execute.after"]({ sessionID: "ses_retry" }, first); + assert.match(first.output, /worker@alpha: mbx_catchup/); + assert.equal(notes.length, 1, "the tool result is the admission; no second synthetic attempt"); + await hooks["tool.execute.after"]({ sessionID: "ses_retry" }, { output: "next" }); + assert.equal(notes.length, 1, "a placed entry is removed from pending"); assert.equal(calls.filter(c => c.args[1] === "session-start").length, 1); assert.equal(calls.filter(c => c.args[1] === "post-tool" && c.input.session_id === "ses_retry").length, 0, "steady retries don't spawn node"); } finally { @@ -286,12 +289,16 @@ test("T187: a resolved native call with no receipt remains pending instead of cl try { const hooks = await mod.AgentMBXHooks({ directory: "/work", session: { synthetic: async (body: Note) => { notes.push(body); return ++attempts === 1 ? {} : { data: { id: body.id } }; - } } }) as { event: (event: unknown) => Promise; "tool.execute.after": (event: unknown) => Promise }; + } } }) as { event: (event: unknown) => Promise; "tool.execute.after": (event: unknown, output?: { output?: string }) => Promise }; await hooks.event({ event: { type: "session.created", properties: { info: { id: "ses_no_receipt" } } } }); - await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" }); - assert.equal(notes.length, 2); assert.equal(notes[0].id, notes[1].id); - await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" }); - assert.equal(notes.length, 2, "recognized data.id closes only the pending admission, not work completion"); + // T700: the no-receipt call did not prove admission, so the note stays pending; the next turn's first tool + // result carries it, and no further synthetic attempt is made. + const first = { output: "tool ok" }; + await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" }, first); + assert.match(first.output, /worker@alpha: mbx_catchup/); + assert.equal(notes.length, 1, "the session API was not asked again"); + await hooks["tool.execute.after"]({ sessionID: "ses_no_receipt" }, { output: "next" }); + assert.equal(notes.length, 1, "a placed entry is removed from pending"); } finally { restore(); rmSync(dir, { recursive: true, force: true }); } }); diff --git a/test/opencode-resume.test.ts b/test/opencode-resume.test.ts index 95cbb2b..f7928c9 100644 --- a/test/opencode-resume.test.ts +++ b/test/opencode-resume.test.ts @@ -100,7 +100,7 @@ test("T187: a quiet resume places the same notice in that tool result and does n } finally { restore(); rmSync(dir, { recursive: true, force: true }); } }); -test("T187: a busy turn keeps the resume note, reports the fallback once, and admits it when the turn ends", async () => { +test("T187: a busy turn keeps the resume note, reports the fallback once, and places it on the next turn's first tool", async () => { const { mod, dir } = await loadPlugin(), notes: Note[] = []; let tools = 0; const restore = stubSpawn([], c => { @@ -119,10 +119,13 @@ test("T187: a busy turn keeps the resume note, reports the fallback once, and ad await hooks["tool.execute.after"]({ sessionID: "ses_busy" }, third); assert.equal(third.output, "third", "the fallback is once per pending note"); await hooks.event({ event: { type: "session.idle", properties: { info: { id: "ses_busy" } } } }); - assert.equal(notes.length, 1); - assert.equal(notes[0].sessionID, "ses_busy"); - assert.equal(notes[0].text, NOTICE); - assert.equal(notes[0].resume, false, "ending the turn admits the note; it does not start a wake"); + assert.equal(notes.length, 0, "T700: ending the turn does not acknowledge a note the host never materialised"); + const next = { output: "next turn" }; + await hooks["tool.execute.after"]({ sessionID: "ses_busy" }, next); + assert.match(next.output, /You are worker@alpha/, "the next turn's first tool result carries the kept note"); + assert.doesNotMatch(next.output, /Resume note was not admitted/); + assert.equal(next.output.split(NOTICE).length - 1, 1, "exactly one copy"); + assert.equal(notes.length, 0); } finally { restore(); rmSync(dir, { recursive: true, force: true }); } }); @@ -189,3 +192,107 @@ test("T187: a host with no session note API keeps the note and a later tool resu restore(); rmSync(dir, { recursive: true, force: true }); } }); + +// ---- T700: the v2 plugin path. OpenCode 2.x validates the default export and dispatches through +// setup(ctx): ctx.event.subscribe() marks a turn at session.execution.started, and ctx.tool.hook is +// the only place a note can ride a tool result. The v1 hooks object is never consumed there. + +type V2Tool = (input: unknown) => unknown; +type V2Module = { default: { setup: (ctx: unknown) => Promise<() => void> } }; + +async function loadPluginV2() { + (globalThis as { __mbxWakePort?: string | null }).__mbxWakePort = null; + const dir = mkdtempSync(join(tmpdir(), "mbx-oc-v2-")); + const file = join(dir, "agentmbx.ts"); + writeFileSync(file, opencodePluginSource(CMD, version())); + return { mod: await import(file) as V2Module, dir }; +} + +function eventStream() { + const queue: unknown[] = []; + let wake: (() => void) | null = null, open = true; + const nudge = () => { const w = wake; wake = null; w?.(); }; + return { + iterable: { + [Symbol.asyncIterator]: () => ({ + next: async () => { + while (!queue.length && open) await new Promise((resolve) => { wake = resolve; }); + return queue.length ? { done: false, value: queue.shift() } : { done: true, value: undefined }; + }, + }), + }, + push(event: unknown) { queue.push(event); nudge(); }, + close() { open = false; nudge(); }, + }; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); +const at = (sid: string) => ({ location: { directory: "/work" }, data: { sessionID: sid } }); + +test("T700 v2: execution.started does not block the first tool of the turn from placing the resume note", async () => { + const { mod, dir } = await loadPluginV2(); + const notes: Note[] = []; + const home = process.env.MBX_HOME; + process.env.MBX_HOME = dir; + const restore = stubSpawn([], (c) => c.args[1] === "post-tool" ? envelope("ses_v2a", NOTICE) : ""); + const stream = eventStream(); + let tool: V2Tool | null = null; + const dispose = await mod.default.setup({ + location: { directory: "/work" }, event: { subscribe: () => stream.iterable }, + tool: { hook: (_name: string, fn: V2Tool) => { tool = fn; } }, session: stubSession(notes), + }); + try { + stream.push({ type: "session.execution.started", ...at("ses_v2a") }); + await tick(); + const result = { sessionID: "ses_v2a", status: "completed", result: { content: "tool output" } }; + await tool!(result); + assert.match(result.result.content, /You are worker@alpha/, "the first tool result carries the bounded notice"); + assert.doesNotMatch(result.result.content, /Resume note was not admitted/); + assert.equal(notes.length, 0, "placing it in the tool result starts no turn and queues no copy"); + } finally { + dispose(); stream.close(); + if (home === undefined) delete process.env.MBX_HOME; else process.env.MBX_HOME = home; + restore(); rmSync(dir, { recursive: true, force: true }); + } +}); + +test("T700 v2: a stop never acknowledges-and-drops a note; the next turn's first tool result places it once", async () => { + const { mod, dir } = await loadPluginV2(); + const notes: Note[] = []; + const home = process.env.MBX_HOME; + process.env.MBX_HOME = dir; + const restore = stubSpawn([], (c) => c.args[1] === "post-tool" ? envelope("ses_v2b", NOTICE) : ""); + const stream = eventStream(); + let tool: V2Tool | null = null; + const dispose = await mod.default.setup({ + location: { directory: "/work" }, event: { subscribe: () => stream.iterable }, + tool: { hook: (_name: string, fn: V2Tool) => { tool = fn; } }, session: stubSession(notes), + }); + try { + stream.push({ type: "session.execution.started", ...at("ses_v2b") }); + await tick(); + // An error result cannot carry a note, so the note stays pending and only the fallback is attempted. + const first = { sessionID: "ses_v2b", status: "error", result: { content: "boom" } }; + await tool!(first); + assert.doesNotMatch(first.result.content, /You are worker@alpha/); + + // The host may return a msg_ receipt without writing a session_message (T544). A stop must not treat that + // as delivered: the note stays pending for the next turn instead of being acknowledged and dropped. + stream.push({ type: "session.execution.succeeded", ...at("ses_v2b") }); + await tick(); + assert.equal(notes.length, 0, "no synthetic admission, and no unwanted turn on an idle session"); + + stream.push({ type: "session.execution.started", ...at("ses_v2b") }); + await tick(); + const second = { sessionID: "ses_v2b", status: "completed", result: { content: "next turn" } }; + await tool!(second); + assert.match(second.result.content, /You are worker@alpha/, "the kept note rides the next turn's first tool result"); + assert.doesNotMatch(second.result.content, /Resume note was not admitted/); + assert.equal(second.result.content.split(NOTICE).length - 1, 1, "exactly one copy"); + assert.equal(notes.length, 0); + } finally { + dispose(); stream.close(); + if (home === undefined) delete process.env.MBX_HOME; else process.env.MBX_HOME = home; + restore(); rmSync(dir, { recursive: true, force: true }); + } +});