From 3929f0343794d03a265694c6791a38544c340406 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 4 Jul 2026 02:43:16 +0800 Subject: [PATCH] feat(hooks): implement async hook execution + asyncRewake delivery Async hooks (async:true) fork into a background fiber scoped to the SettingsHook service layer; their output never participates in the current TriggerResult. asyncRewake:true delivers rewake-worthy output (exit 2 stderr, systemMessage, decision:block, additionalContext) back to the agent via the HookRewake bridge service driving the V1 SessionPrompt loop. - src/hook/rewake.ts: tag-only leaf service (no import cycle) - src/hook/rewake-live.ts: live impl via SessionPrompt.prompt, wired in AppLayer + httpapi server node graph (dual composition system) - settings.ts: async fork branch in trigger entry loop, onAsyncComplete rewake callback, HOOK_REWAKE_SENTINEL + buildRewakePrompt - prompt.ts: loop guard skips UserPromptSubmit hooks for sentinel-prefixed prompts (prevents hook -> rewake -> hook loops) - 9 integration tests (async non-blocking, output isolation, rewake e2e, suppression, once-at-fork, sentinel validation) --- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/hook/rewake-live.ts | 52 +++ packages/opencode/src/hook/rewake.ts | 23 ++ packages/opencode/src/hook/session-hooks.ts | 2 + packages/opencode/src/hook/settings.ts | 121 +++++- .../server/routes/instance/httpapi/server.ts | 7 + packages/opencode/src/session/prompt.ts | 12 +- .../opencode/test/hook/async-rewake.test.ts | 368 ++++++++++++++++++ 8 files changed, 578 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/hook/rewake-live.ts create mode 100644 packages/opencode/src/hook/rewake.ts create mode 100644 packages/opencode/test/hook/async-rewake.test.ts diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 61c4d6bf62..61268ac3a5 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -53,6 +53,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { HookStartContext } from "@/hook/start-context" import { SettingsHook } from "@/hook/settings" +import { HookRewakeLive } from "@/hook/rewake-live" import { Goal } from "@/goal/goal" import { GoalLoop } from "@/goal/loop" @@ -91,6 +92,7 @@ export const AppLayer = Layer.mergeAll( SessionRevert.defaultLayer, SessionSummary.defaultLayer, SessionPrompt.defaultLayer, + HookRewakeLive.liveLayer, Instruction.defaultLayer, LLM.defaultLayer, LSP.defaultLayer, diff --git a/packages/opencode/src/hook/rewake-live.ts b/packages/opencode/src/hook/rewake-live.ts new file mode 100644 index 0000000000..12e498d2f0 --- /dev/null +++ b/packages/opencode/src/hook/rewake-live.ts @@ -0,0 +1,52 @@ +/** + * Live HookRewake implementation — drives the V1 SessionPrompt loop. + * + * Dependency graph (no cycle): + * rewake.ts (tag only) ← settings.ts ← prompt.ts ← this file → rewake.ts + * + * SessionPrompt.Service is resolved lazily at call time (not at construction) + * so this layer has no construction-time requirements and composes cleanly + * in Layer.mergeAll. The rewake runs from the SettingsHook trigger context + * (inside AppLayer), so SessionPrompt is always available there. + * + * The loop guard in prompt.ts (UserPromptSubmit trigger skips prompts whose + * text starts with HOOK_REWAKE_SENTINEL) IS on this path — SessionPrompt.prompt + * fires UserPromptSubmit hooks internally, so rewake prompts are guarded. + */ +import { Effect, Layer, Option } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { HookRewake } from "./rewake" +import { SessionPrompt } from "@/session/prompt" +import { SessionID } from "@/session/schema" + +export const liveLayer = Layer.succeed( + HookRewake.Service, + HookRewake.Service.of({ + rewake: (input) => + Effect.gen(function* () { + const sessionPrompt = Option.getOrUndefined(yield* Effect.serviceOption(SessionPrompt.Service)) + if (!sessionPrompt) return + yield* sessionPrompt + .prompt({ + sessionID: SessionID.make(input.sessionID), + parts: [{ type: "text", text: input.text }], + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("hook rewake prompt failed", { + sessionID: input.sessionID, + error: String(error), + }), + ), + Effect.asVoid, + ) + }), + }), +) + +// SessionPrompt is resolved lazily at call time, so the node declares no +// construction-time dependencies. The httpapi server app graph and AppLayer +// both contain SessionPrompt, which is what the call-time serviceOption sees. +export const node = LayerNode.make(liveLayer, []) + +export * as HookRewakeLive from "./rewake-live" diff --git a/packages/opencode/src/hook/rewake.ts b/packages/opencode/src/hook/rewake.ts new file mode 100644 index 0000000000..fbee871d52 --- /dev/null +++ b/packages/opencode/src/hook/rewake.ts @@ -0,0 +1,23 @@ +/** + * HookRewake — bridge service tag for async hook rewake delivery. + * + * Tag-only leaf module: imports nothing from prompt.ts or settings.ts, so + * there is no import cycle. The live implementation lives in rewake-live.ts + * (depends on this tag + SessionPrompt.Service); app-runtime and the httpapi + * server node graph wire it. + * + * Deliberately NO default/no-op layer here: settings.ts resolves this service + * via `Effect.serviceOption` and degrades gracefully (log.warn + skip) when it + * is absent. A no-op layer would risk masking the live one if both were ever + * wired into the same graph. + */ +import { Context, Effect } from "effect" +import { SessionID } from "@/session/schema" + +export interface Interface { + readonly rewake: (input: { sessionID: SessionID; text: string }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HookRewake") {} + +export * as HookRewake from "./rewake" diff --git a/packages/opencode/src/hook/session-hooks.ts b/packages/opencode/src/hook/session-hooks.ts index da30828413..54f5a81ba1 100644 --- a/packages/opencode/src/hook/session-hooks.ts +++ b/packages/opencode/src/hook/session-hooks.ts @@ -31,7 +31,9 @@ export interface SessionHookCommand { timeout?: number shell?: "bash" | "powershell" if?: string + /** Background execution — see HookCommand.async in settings.ts. */ async?: boolean + /** Deliver async result to agent — see HookCommand.asyncRewake in settings.ts. */ asyncRewake?: boolean options?: Record __sourceDir?: string diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 94fc0d5905..a26e526f95 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -42,7 +42,7 @@ import os from "os" import { existsSync, readFileSync } from "fs" import { spawn } from "child_process" import { createHash } from "crypto" -import { Effect, Layer, Context, Option } from "effect" +import { Effect, Layer, Context, Option, Scope, Exit } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" @@ -63,6 +63,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import type { SessionHookEntry } from "./session-hooks" import { SessionID } from "@/session/schema" +import { HookRewake } from "./rewake" import { buildForkHooks, watchSettings } from "./extensions" // [FORK:hook-ext] const log = Log.create({ service: "hook.settings" }) @@ -161,13 +162,21 @@ export interface HookCommand { */ if?: string /** - * Async execution flag. CC's AsyncHookRegistry routes async hooks via attachments / task-notification. - * Fork currently runs everything sync; this is a P2 schema placeholder. + * Async execution flag. When true, the hook is forked into a background fiber + * scoped to the SettingsHook service; its output never participates in the + * current trigger's TriggerResult (no permissionDecision, blocked, etc. — the + * decision point has already passed by the time it completes). Background + * fibers die with the process; there is no durable persistence across crashes. + * Pair with `asyncRewake` to deliver the result back to the agent. */ async?: boolean /** - * Companion to `async`: when an async hook exits with code 2, CC re-wakes the agent via - * `wrapInSystemReminder` + `task-notification`. Schema placeholder for the same P2 work. + * Companion to `async`: when an async hook completes with rewake-worthy output + * (exit code 2 stderr, `systemMessage`, `decision:"block"` reason, or + * `additionalContext`), a synthetic steer prompt wrapping the output in a + * `` is admitted to the originating session, re-waking the agent. + * Without `async`, this field is inert (the hook runs synchronously as usual). + * Rewake is suppressed for `SessionEnd` and when no `sessionID` is available. */ asyncRewake?: boolean /** @@ -259,6 +268,17 @@ const HookJSONOutputZodSchema = z.object({ hookSpecificOutput: HookSpecificOutputZodSchema.optional(), }) +// ── Async rewake (hook-async-rewake) ─────────────────────────── +// Sentinel prefix for rewake prompts. UserPromptSubmit hook processing skips +// prompts whose text starts with this prefix, preventing hook → rewake → hook +// loops. The prefix must match the opening of buildRewakePrompt exactly. +export const HOOK_REWAKE_SENTINEL = "\nAsync hook completed" + +function buildRewakePrompt(entry: HookCommand, event: HookEvent, content: string): string { + const cmd = descriptorFor(entry) + return `${HOOK_REWAKE_SENTINEL} (command: ${cmd}, event: ${event}):\n${content}\n` +} + /** * hookSpecificOutput discriminated union — Claude Code 1:1. * 仅 5 个事件有 union 分支;Stop / SubagentStop / PreCompact / SessionEnd @@ -1653,6 +1673,60 @@ export const layer = Layer.effect( return yield* handler.run(entry as never, envelope, cwd, inHook) }) + // Background scope for async hooks. Lives as long as the SettingsHook service + // instance; closed via finalizer when the instance is disposed. + const bgScope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(bgScope, Exit.void)) + + /** + * Async hook completion handler. Computes rewake-worthiness from the result + * and, when `asyncRewake` is set, admits a steer prompt to the originating + * session via the opencode Session.Service (ambient in the session runtime). + * Best-effort — all errors swallowed to honor the "never crash host" contract. + */ + const onAsyncComplete = Effect.fnUntraced(function* ( + entry: HookCommand, + event: HookEvent, + sessionID: string | undefined, + hookRewake: HookRewake.Interface | undefined, + result: { json?: HookJSONOutput | undefined; exitBlock?: string | undefined }, + ) { + if (!entry.asyncRewake) { + log.debug("async hook completed (no rewake)", { command: commandText(entry).slice(0, 80) }) + return + } + if (event === "SessionEnd") return + if (!sessionID) { + log.warn("async hook rewake skipped: no sessionID", { command: commandText(entry).slice(0, 80) }) + return + } + if (!hookRewake) { + log.warn("async hook rewake skipped: HookRewake.Service unavailable", { command: commandText(entry).slice(0, 80) }) + return + } + + const parts: string[] = [] + if (result.exitBlock) parts.push(result.exitBlock) + if (result.json?.systemMessage) parts.push(result.json.systemMessage) + if (result.json?.decision === "block" && result.json.reason) parts.push(result.json.reason) + const hso = result.json?.hookSpecificOutput + if (hso && "additionalContext" in hso && typeof hso.additionalContext === "string") { + parts.push(hso.additionalContext) + } + if (parts.length === 0) { + log.debug("async hook completed: nothing rewake-worthy", { command: commandText(entry).slice(0, 80) }) + return + } + + const text = buildRewakePrompt(entry, event, parts.join("\n")) + yield* hookRewake.rewake({ sessionID: SessionID.make(sessionID), text }).pipe( + Effect.catchDefect((defect) => { + log.warn("async hook rewake defect swallowed", { command: commandText(entry).slice(0, 80), error: String(defect) }) + return Effect.void + }), + ) + }) + const trigger = Effect.fn("SettingsHook.trigger")(function* ( payload: HookPayload, ctx: TriggerContext, @@ -1771,6 +1845,43 @@ export const layer = Layer.effect( // [FORK:hook-ext] Pre-dispatch filter — skip entry if condition not met if (forkHooks?.beforeRunEntry && !forkHooks.beforeRunEntry(entry, envelope, payload.event)) continue + // ── Async fork (hook-async-rewake) ────────────────────── + // async:true entries are forked into the background and do NOT + // participate in the current TriggerResult aggregation. Their output + // (when asyncRewake:true) is delivered back via onAsyncComplete → + // Session.rewake once the background fiber settles. + if (entry.async) { + if (group._sessionEntry?.once && ctx.sessionID) { + yield* sessionHooks.remove(SessionID.make(ctx.sessionID), group._sessionEntry.id) + } + const hookRewake = Option.getOrUndefined(yield* Effect.serviceOption(HookRewake.Service)) + const capturedEvent = payload.event + const capturedSessionID = ctx.sessionID + yield* runEntry(entry, envelope, s.cwd, false).pipe( + Effect.catchDefect((defect) => { + log.warn("async hook entry defect swallowed (host protected)", { + event: capturedEvent, + command: commandText(entry), + error: String(defect), + }) + return Effect.succeed({ + json: undefined as HookJSONOutput | undefined, + exitBlock: undefined as string | undefined, + }) + }), + Effect.flatMap((r) => onAsyncComplete(entry, capturedEvent, capturedSessionID, hookRewake, r)), + Effect.catchDefect((defect) => { + log.warn("async hook completion defect swallowed", { + command: commandText(entry), + error: String(defect), + }) + return Effect.void + }), + Effect.forkIn(bgScope), + ) + continue + } + // "Never crash host" contract: a hook handler can throw an unrecoverable // defect (null deref, OOM, native assert). Effect.catch at the tool-layer // call sites only catches typed Failures, so a defect would propagate and diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index b7afb47a69..3dc92b6b19 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -47,6 +47,7 @@ import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" import { Goal } from "@/goal/goal" import { SettingsHook } from "@/hook/settings" +import { HookRewakeLive } from "@/hook/rewake-live" import { SessionHooks } from "@/hook/session-hooks" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" @@ -273,6 +274,12 @@ const app = LayerNode.group([ // Permission.node / Compaction.node / ShareSession.node etc.). SettingsHook.node, SessionHooks.node, + // HookRewake live node: async hook rewake delivery (hook-async-rewake). + // Same app-graph-level placement rationale as SettingsHook above — settings.ts + // resolves HookRewake.Service via serviceOption from the ambient request + // context, so it must be present in this graph or server-driven sessions + // would silently skip rewake. + HookRewakeLive.node, ]) export function createRoutes( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f8ea0fbcad..01630f027c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -60,7 +60,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" -import { SettingsHook } from "@/hook/settings" +import { SettingsHook, HOOK_REWAKE_SENTINEL } from "@/hook/settings" import { HookStartContext } from "@/hook/start-context" import { Goal } from "@/goal/goal" @@ -1230,12 +1230,16 @@ export const layer = Layer.effect( const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) - // SettingsHook: UserPromptSubmit — gives hooks a chance to block or modify the turn + // SettingsHook: UserPromptSubmit — gives hooks a chance to block or modify the turn. + // Loop guard (hook-async-rewake): skip hooks for rewake prompts (those whose + // text starts with HOOK_REWAKE_SENTINEL) to prevent hook → rewake → hook loops. let hookAdditionalContexts: string[] = [] - if (settingsHook) { + const promptText = input.parts.map((p: any) => p.type === "text" ? p.text : "").join("\n") + const isRewake = promptText.startsWith(HOOK_REWAKE_SENTINEL) + if (settingsHook && !isRewake) { const hookResult = yield* settingsHook .trigger( - { event: "UserPromptSubmit", prompt: input.parts.map((p: any) => p.type === "text" ? p.text : "").join("\n") }, + { event: "UserPromptSubmit", prompt: promptText }, { sessionID: input.sessionID, transcriptPath: "" }, ) .pipe(Effect.catch(() => Effect.succeed({ blocked: undefined, additionalContexts: [] as string[] } as any))) diff --git a/packages/opencode/test/hook/async-rewake.test.ts b/packages/opencode/test/hook/async-rewake.test.ts new file mode 100644 index 0000000000..f415af7498 --- /dev/null +++ b/packages/opencode/test/hook/async-rewake.test.ts @@ -0,0 +1,368 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { SettingsHook, HOOK_REWAKE_SENTINEL } from "@/hook/settings" +import { HookRewake } from "@/hook/rewake" +import { SessionHooks } from "@/hook/session-hooks" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "@/session/schema" +import { testEffect, pollWithTimeout } from "../lib/effect" + +// ── Test layer ────────────────────────────────────────────────── +const rewakeCalls: { sessionID: string; text: string }[] = [] +const mockHookRewakeLayer = Layer.succeed( + HookRewake.Service, + HookRewake.Service.of({ + rewake: (input) => + Effect.sync(() => { + rewakeCalls.push(input) + }), + }), +) + +const testLayer = SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), + Layer.provideMerge(mockHookRewakeLayer), +) + +const it = testEffect(testLayer) + +const writeSettingsTo = (hooksJson: object) => (dir: string) => + Effect.promise(() => + fs.mkdir(path.join(dir, ".opencode"), { recursive: true }).then(() => + fs.writeFile(path.join(dir, ".opencode", "hooks.json"), JSON.stringify(hooksJson)), + ), + ) + +const tmp = (name: string) => path.join(os.tmpdir(), `opencode-test-${name}`) + +describe("hook-async-rewake", () => { + // ── 4.1: Async hook does not block the trigger ─────────────── + const marker41 = tmp("async-4-1.txt") + it.instance( + "async hook does not delay trigger — marker file appears after trigger returns", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + yield* Effect.promise(() => fs.rm(marker41, { force: true })) + + const r = yield* hook.trigger( + { event: "SessionStart", source: "startup" }, + { sessionID: "sess-4-1", transcriptPath: "" }, + ) + + expect(r.additionalContexts).toEqual([]) + expect(r.systemMessages).toEqual([]) + expect(r.blocked).toBeUndefined() + + yield* pollWithTimeout( + Effect.promise(() => + fs + .access(marker41) + .then(() => true as const) + .catch(() => undefined), + ), + "async hook marker file never appeared", + ) + }), + { + init: writeSettingsTo({ + SessionStart: [ + { + hooks: [ + { + type: "command", + command: `sleep 0.3 && touch ${JSON.stringify(marker41)}`, + async: true, + }, + ], + }, + ], + }), + }, + ) + + // ── 4.2: Async output cannot gate a decision ───────────────── + it.instance( + "async PreToolUse hook exit-2 does not block", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + + const r = yield* hook.trigger( + { event: "PreToolUse", toolName: "Write", toolInput: {} }, + { sessionID: "sess-4-2", transcriptPath: "" }, + ) + + expect(r.blocked).toBeUndefined() + expect(r.permissionDecision).toBeUndefined() + }), + { + init: writeSettingsTo({ + PreToolUse: [ + { + matcher: "Write", + hooks: [{ type: "command", command: "echo blocked >&2 && exit 2", async: true }], + }, + ], + }), + }, + ) + + // ── 4.3: Rewake end-to-end ─────────────────────────────────── + it.instance( + "async+asyncRewake exit-2 admits a steer prompt with sentinel + stderr", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + + const r = yield* hook.trigger( + { event: "PostToolUse", toolName: "Write", toolInput: {}, toolResponse: {} }, + { sessionID: "sess-4-3", transcriptPath: "" }, + ) + + expect(r.blocked).toBeUndefined() + + yield* pollWithTimeout( + Effect.sync(() => rewakeCalls[0]), + "rewake was never called", + ) + expect(rewakeCalls[0].sessionID).toBe("sess-4-3") + expect(rewakeCalls[0].text.startsWith(HOOK_REWAKE_SENTINEL)).toBe(true) + expect(rewakeCalls[0].text).toContain("rewake-payload-4-3") + expect(rewakeCalls[0].text).toContain("") + }), + { + init: writeSettingsTo({ + PostToolUse: [ + { + matcher: "Write", + hooks: [ + { + type: "command", + command: "echo rewake-payload-4-3 >&2 && exit 2", + async: true, + asyncRewake: true, + }, + ], + }, + ], + }), + }, + ) + + // ── 4.4a: exit-0 admits nothing ────────────────────────────── + // Readiness signal: hook writes a marker when its shell command finishes. + // runEntry resolve → onAsyncComplete is a synchronous flatMap continuation, + // so marker-visible == onAsyncComplete's early-return already executed. + // Asserting after marker is deterministic, not a time-window guess. + const marker44a = tmp("async-4-4a.txt") + it.instance( + "exit-0 asyncRewake hook admits nothing", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + yield* Effect.promise(() => fs.rm(marker44a, { force: true })) + + yield* hook.trigger({ event: "Stop", stopHookActive: false }, { sessionID: "sess-4-4a", transcriptPath: "" }) + + yield* pollWithTimeout( + Effect.promise(() => + fs + .access(marker44a) + .then(() => true as const) + .catch(() => undefined), + ), + "4.4a hook marker never appeared — fiber did not reach onAsyncComplete", + ) + // Marker visible ⇒ runEntry done ⇒ onAsyncComplete (sync flatMap) already + // hit the parts.length === 0 early return. Deterministic. + expect(rewakeCalls.length).toBe(0) + }), + { + init: writeSettingsTo({ + Stop: [ + { + hooks: [ + { + type: "command", + command: `echo ok && touch ${JSON.stringify(marker44a)}`, + async: true, + asyncRewake: true, + }, + ], + }, + ], + }), + }, + ) + + // ── 4.4b: SessionEnd never rewakes ─────────────────────────── + const marker44b = tmp("async-4-4b.txt") + it.instance( + "SessionEnd asyncRewake hook never rewakes", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + yield* Effect.promise(() => fs.rm(marker44b, { force: true })) + + yield* hook.trigger({ event: "SessionEnd", reason: "other" }, { sessionID: "sess-4-4b", transcriptPath: "" }) + + yield* pollWithTimeout( + Effect.promise(() => + fs + .access(marker44b) + .then(() => true as const) + .catch(() => undefined), + ), + "4.4b hook marker never appeared — fiber did not reach onAsyncComplete", + ) + // Marker visible ⇒ onAsyncComplete already hit the `event === "SessionEnd"` early return. + expect(rewakeCalls.length).toBe(0) + }), + { + init: writeSettingsTo({ + SessionEnd: [ + { + hooks: [ + { + type: "command", + // touch BEFORE exit 2 so the marker is written despite the non-zero exit + command: `touch ${JSON.stringify(marker44b)} && echo ending >&2 && exit 2`, + async: true, + asyncRewake: true, + }, + ], + }, + ], + }), + }, + ) + + // ── 4.4c: missing sessionID skips rewake ───────────────────── + const marker44c = tmp("async-4-4c.txt") + it.instance( + "missing sessionID skips rewake", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + yield* Effect.promise(() => fs.rm(marker44c, { force: true })) + + yield* hook.trigger({ event: "Stop", stopHookActive: false }, { sessionID: "", transcriptPath: "" }) + + yield* pollWithTimeout( + Effect.promise(() => + fs + .access(marker44c) + .then(() => true as const) + .catch(() => undefined), + ), + "4.4c hook marker never appeared — fiber did not reach onAsyncComplete", + ) + // Marker visible ⇒ onAsyncComplete already hit the `!sessionID` early return. + expect(rewakeCalls.length).toBe(0) + }), + { + init: writeSettingsTo({ + Stop: [ + { + hooks: [ + { + type: "command", + command: `touch ${JSON.stringify(marker44c)} && echo nosession >&2 && exit 2`, + async: true, + asyncRewake: true, + }, + ], + }, + ], + }), + }, + ) + + // ── 4.5: asyncRewake without async is inert ────────────────── + it.instance( + "asyncRewake:true without async:true runs synchronously", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + + yield* hook.trigger({ event: "Stop", stopHookActive: false }, { sessionID: "sess-4-5", transcriptPath: "" }) + expect(rewakeCalls.length).toBe(0) + }), + { + init: writeSettingsTo({ + Stop: [{ hooks: [{ type: "command", command: "echo ok", asyncRewake: true }] }], + }), + }, + ) + + // ── 4.6: Loop guard — sentinel prefix ──────────────────────── + it.instance( + "HOOK_REWAKE_SENTINEL is a stable prefix of rewake prompts", + () => + Effect.gen(function* () { + expect(typeof HOOK_REWAKE_SENTINEL).toBe("string") + expect(HOOK_REWAKE_SENTINEL.length).toBeGreaterThan(0) + expect(HOOK_REWAKE_SENTINEL.startsWith("")).toBe(true) + }), + ) + + // ── 4.7: once async session entry removed at fork time ─────── + const marker47 = tmp("once-4-7.txt") + it.instance( + "once:true async session hook fires only once across two triggers", + () => + Effect.gen(function* () { + rewakeCalls.length = 0 + const hook = yield* SettingsHook.Service + const sessionHooks = yield* SessionHooks.Service + yield* Effect.promise(() => fs.rm(marker47, { force: true })) + + yield* sessionHooks.add(SessionID.make("sess-4-7"), { + event: "Stop", + once: true, + hooks: [ + { + type: "command", + command: `sleep 0.2 && touch ${JSON.stringify(marker47)}`, + async: true, + }, + ], + }) + + // First trigger — forks the hook, removes entry at fork time + yield* hook.trigger({ event: "Stop", stopHookActive: false }, { sessionID: "sess-4-7", transcriptPath: "" }) + + // Second trigger immediately — entry already removed + const r2 = yield* hook.trigger({ event: "Stop", stopHookActive: false }, { sessionID: "sess-4-7", transcriptPath: "" }) + expect(r2.additionalContexts).toEqual([]) + + // Verify the first hook's marker appeared + yield* pollWithTimeout( + Effect.promise(() => + fs + .access(marker47) + .then(() => true as const) + .catch(() => undefined), + ), + "once-marker never appeared", + ) + + const remaining = yield* sessionHooks.list(SessionID.make("sess-4-7"), "Stop") + expect(remaining.length).toBe(0) + }), + ) +})