Skip to content
Open
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,13 +372,17 @@ Tool-arg matching is exact. This example runs only when the tool arg `path` equa
},
{
"id": "session-idle",
"when": { "event": "session.idle" },
"run": ["echo 'Session idle'"],
"when": { "event": "session.idle", "excludeSubagentWait": true },
"run": ["notify-user.sh 'Waiting for input'"],
},
],
}
```

`excludeSubagentWait` is opt-in. When true, that `session.idle` hook waits until all
active `task` subagent calls for the session have completed. Other idle hooks keep
their existing behavior and still run while the parent is waiting on a subagent.

---

## Template Placeholders
Expand Down
7 changes: 6 additions & 1 deletion src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const matches = (pattern: string | string[] | undefined, value: string |
*/
export const filterSessionHooks = (
hooks: SessionHook[],
criteria: { event: string; agent: string | undefined }
criteria: { event: string; agent: string | undefined; hasActiveSubagents?: boolean }
): SessionHook[] => {
return hooks.filter((hook) => {
// Normalize session.start to session.created
Expand All @@ -51,6 +51,11 @@ export const filterSessionHooks = (
}

if (normalizedEvent !== criteria.event) return false
if (
normalizedEvent === "session.idle" &&
hook.when.excludeSubagentWait &&
criteria.hasActiveSubagents
) return false

if (hook.when.agent && !criteria.agent) {
logger.debug(
Expand Down
56 changes: 42 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { normalizeString } from "./utils.js"
import { loadGlobalConfig } from "./config/global.js"
import { loadAgentConfig } from "./config/agent.js"
import { mergeConfigs } from "./config/merge.js"
import { createActiveSubagentTracker } from "./subagent-tracker.js"

const notifyConfigError = async (
configError: string | null,
Expand Down Expand Up @@ -131,7 +132,8 @@ const handleSessionEvent = async (
eventType: "session.created" | "session.idle",
sessionId: string | undefined,
agent: string | undefined,
client: OpencodeClient
client: OpencodeClient,
hasActiveSubagents: boolean,
): Promise<void> => {
if (!sessionId) {
logger.debug(`${eventType} event missing session ID`)
Expand All @@ -152,6 +154,7 @@ const handleSessionEvent = async (
const matchedHooks = filterSessionHooks(mergedConfig.session || [], {
event: eventType,
agent,
hasActiveSubagents,
})

logger.debug(
Expand Down Expand Up @@ -253,6 +256,7 @@ const handleToolExecutionHook = async (
export const CommandHooksPlugin: Plugin = async ({ client }) => {
const clientLogger = createLogger(client)
setGlobalLogger(clientLogger)
const activeSubagents = createActiveSubagentTracker()

try {
logger.info("Initializing OpenCode Command Hooks plugin...")
Expand Down Expand Up @@ -295,17 +299,35 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => {
const sessionId = info?.id ? normalizeString(info.id) : undefined
const agent = normalizeString(event.properties?.agent)

await handleSessionEvent("session.created", sessionId, agent, client as OpencodeClient)
}

// Handle session.idle event
await handleSessionEvent(
"session.created",
sessionId,
agent,
client as OpencodeClient,
sessionId ? activeSubagents.hasActive(sessionId) : false,
)
}

if (event.type === "session.deleted") {
const info = event.properties?.info as { id?: string } | undefined
const sessionId = info?.id ? normalizeString(info.id) : undefined
if (sessionId) activeSubagents.clear(sessionId)
}

// Handle session.idle event
if (event.type === "session.idle") {
logger.debug("Received session.idle event")

const sessionId = normalizeString(event.properties?.sessionID)
const agent = normalizeString(event.properties?.agent)

await handleSessionEvent("session.idle", sessionId, agent, client as OpencodeClient)
await handleSessionEvent(
"session.idle",
sessionId,
agent,
client as OpencodeClient,
sessionId ? activeSubagents.hasActive(sessionId) : false,
)
}

// Backward-compat fallback for older OpenCode event streams.
Expand All @@ -323,14 +345,16 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => {
event.properties?.callID ?? event.properties?.callId
)

if (!sessionId || !toolName) {
if (!sessionId || !toolName) {
logger.debug(
"tool.result event missing sessionID or tool name"
)
return
}
return
}

if (wasAfterHookProcessed(callId)) {
if (toolName === "task") activeSubagents.end(sessionId, callId)

if (wasAfterHookProcessed(callId)) {
logger.debug(`Skipping duplicate after-hook execution for callID: ${callId}`)
deleteToolArgs(callId)
return
Expand Down Expand Up @@ -361,6 +385,8 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => {
`Tool args: ${JSON.stringify(output.args)}`
)

if (input.tool === "task") activeSubagents.begin(input.sessionID, input.callID)

await handleToolExecutionHook("before", input, output.args, client as OpencodeClient)
},

Expand All @@ -376,13 +402,15 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => {
`Received tool.execute.after for tool: ${input.tool}`
)

if (!toolOutput) {
if (!toolOutput) {
logger.debug(
`tool.execute.after for ${input.tool} has no output payload; running hooks with cached args only`
)
}
)
}

if (input.tool === "task") activeSubagents.end(input.sessionID, input.callID)

const storedToolArgs = getToolArgs(input.callID)
const storedToolArgs = getToolArgs(input.callID)
await handleToolExecutionHook("after", input, storedToolArgs, client as OpencodeClient)
},
}
Expand Down
1 change: 1 addition & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const ToolHookSchema = z.object({
const SessionHookWhenSchema = z.object({
event: SessionEventSchema,
agent: StringOrArray.optional(),
excludeSubagentWait: z.boolean().optional(),
});

/**
Expand Down
45 changes: 45 additions & 0 deletions src/subagent-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type ActiveSubagentTracker = {
begin: (sessionId: string, callId?: string) => void
end: (sessionId: string, callId?: string) => void
hasActive: (sessionId: string) => boolean
clear: (sessionId: string) => void
}

type ActiveCalls = {
ids: Set<string>
anonymous: number
}

export const createActiveSubagentTracker = (): ActiveSubagentTracker => {
const sessions = new Map<string, ActiveCalls>()

const state = (sessionId: string): ActiveCalls => {
const existing = sessions.get(sessionId)
if (existing) return existing
const created = { ids: new Set<string>(), anonymous: 0 }
sessions.set(sessionId, created)
return created
}

return {
begin: (sessionId, callId) => {
const active = state(sessionId)
if (callId) active.ids.add(callId)
else active.anonymous += 1
},
end: (sessionId, callId) => {
const active = sessions.get(sessionId)
if (!active) return
if (callId) active.ids.delete(callId)
else active.anonymous = Math.max(0, active.anonymous - 1)
if (active.ids.size === 0 && active.anonymous === 0) sessions.delete(sessionId)
},
hasActive: sessionId => {
const active = sessions.get(sessionId)
return active !== undefined && (active.ids.size > 0 || active.anonymous > 0)
},
clear: sessionId => {
sessions.delete(sessionId)
},
}
}
3 changes: 3 additions & 0 deletions src/types/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ export interface SessionHookWhen {
* Omitted: defaults to "*" in global config, "this agent" in markdown.
*/
agent?: string | string[]

/** Skip this idle hook while the session has active subagent tool calls. */
excludeSubagentWait?: boolean
}

/**
Expand Down
115 changes: 115 additions & 0 deletions tests/session.subagent-wait.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import { parseSessionHook } from "../src/schemas"

const originalCwd = process.cwd()

describe("session idle subagent waits", () => {
let directory: string

beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), "opencode-hooks-subagent-wait-"))
mkdirSync(join(directory, ".opencode"), { recursive: true })
writeFileSync(
join(directory, ".opencode", "command-hooks.jsonc"),
JSON.stringify({
session: [
{ id: "always", when: { event: "session.idle" }, inject: "always" },
{
id: "after-subagents",
when: { event: "session.idle", excludeSubagentWait: true },
inject: "after-subagents",
},
],
}),
)
process.chdir(directory)
})

afterEach(() => {
process.chdir(originalCwd)
rmSync(directory, { recursive: true, force: true })
})

it("accepts excludeSubagentWait as an opt-in session hook condition", () => {
const hook = parseSessionHook({
id: "notify",
when: { event: "session.idle", excludeSubagentWait: true },
inject: "notify",
})

expect(hook?.when.excludeSubagentWait).toBe(true)
})

it("suppresses only opted-in idle hooks until every task call finishes", async () => {
const injected: string[] = []
const client = {
session: {
promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => {
injected.push(body.parts[0].text)
return {}
},
},
tui: { showToast: async () => ({}) },
}
const { CommandHooksPlugin } = await import("../src/index.js")
const plugin = await CommandHooksPlugin({ client } as never)
const idle = () => plugin.event?.({
event: { type: "session.idle", properties: { sessionID: "parent" } },
} as never)

await plugin["tool.execute.before"]?.(
{ tool: "task", sessionID: "parent", callID: "task-1" },
{ args: { subagent_type: "worker" } },
)
await plugin["tool.execute.before"]?.(
{ tool: "task", sessionID: "parent", callID: "task-2" },
{ args: { subagent_type: "worker" } },
)
await idle()

await plugin["tool.execute.after"]?.(
{ tool: "task", sessionID: "parent", callID: "task-1" },
undefined as never,
)
await idle()

await plugin["tool.execute.after"]?.(
{ tool: "task", sessionID: "parent", callID: "task-2" },
undefined as never,
)
await idle()

expect(injected).toEqual(["always", "always", "always", "after-subagents"])
})

it("clears active subagent state when the session is deleted", async () => {
const injected: string[] = []
const client = {
session: {
promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => {
injected.push(body.parts[0].text)
return {}
},
},
tui: { showToast: async () => ({}) },
}
const { CommandHooksPlugin } = await import("../src/index.js")
const plugin = await CommandHooksPlugin({ client } as never)

await plugin["tool.execute.before"]?.(
{ tool: "task", sessionID: "deleted", callID: "task-deleted" },
{ args: { subagent_type: "worker" } },
)
await plugin.event?.({
event: { type: "session.deleted", properties: { info: { id: "deleted" } } },
} as never)
await plugin.event?.({
event: { type: "session.idle", properties: { sessionID: "deleted" } },
} as never)

expect(injected).toEqual(["always", "after-subagents"])
})
})
Loading