diff --git a/README.md b/README.md index d60b03b..bba4307 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/executor.ts b/src/executor.ts index d25d5b7..d51b695 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -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 @@ -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( diff --git a/src/index.ts b/src/index.ts index da99b86..8ee583b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -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 => { if (!sessionId) { logger.debug(`${eventType} event missing session ID`) @@ -152,6 +154,7 @@ const handleSessionEvent = async ( const matchedHooks = filterSessionHooks(mergedConfig.session || [], { event: eventType, agent, + hasActiveSubagents, }) logger.debug( @@ -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...") @@ -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. @@ -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 @@ -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) }, @@ -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) }, } diff --git a/src/schemas.ts b/src/schemas.ts index 65959b3..82542bc 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -97,6 +97,7 @@ export const ToolHookSchema = z.object({ const SessionHookWhenSchema = z.object({ event: SessionEventSchema, agent: StringOrArray.optional(), + excludeSubagentWait: z.boolean().optional(), }); /** diff --git a/src/subagent-tracker.ts b/src/subagent-tracker.ts new file mode 100644 index 0000000..6400239 --- /dev/null +++ b/src/subagent-tracker.ts @@ -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 + anonymous: number +} + +export const createActiveSubagentTracker = (): ActiveSubagentTracker => { + const sessions = new Map() + + const state = (sessionId: string): ActiveCalls => { + const existing = sessions.get(sessionId) + if (existing) return existing + const created = { ids: new Set(), 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) + }, + } +} diff --git a/src/types/hooks.ts b/src/types/hooks.ts index ee51200..2050a2c 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -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 } /** diff --git a/tests/session.subagent-wait.test.ts b/tests/session.subagent-wait.test.ts new file mode 100644 index 0000000..3bd34e7 --- /dev/null +++ b/tests/session.subagent-wait.test.ts @@ -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"]) + }) +})