Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -91,6 +92,7 @@ export const AppLayer = Layer.mergeAll(
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionPrompt.defaultLayer,
HookRewakeLive.liveLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
LSP.defaultLayer,
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/src/hook/rewake-live.ts
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 23 additions & 0 deletions packages/opencode/src/hook/rewake.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/HookRewake") {}

export * as HookRewake from "./rewake"
2 changes: 2 additions & 0 deletions packages/opencode/src/hook/session-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
__sourceDir?: string
Expand Down
121 changes: 116 additions & 5 deletions packages/opencode/src/hook/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" })
Expand Down Expand Up @@ -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
* `<system-reminder>` 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
/**
Expand Down Expand Up @@ -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 = "<system-reminder>\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</system-reminder>`
}

/**
* hookSpecificOutput discriminated union — Claude Code 1:1.
* 仅 5 个事件有 union 分支;Stop / SubagentStop / PreCompact / SessionEnd
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 8 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)))
Expand Down
Loading
Loading