From eb11b80d7eb23160e2f3e901fb9ffd8ec143b971 Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:52:08 -0700 Subject: [PATCH 1/4] feat: scope session hooks to parent sessions --- README.md | 1 + src/executor.ts | 9 +++- src/index.ts | 17 ++++++ src/schemas.ts | 2 + src/types/hooks.ts | 7 ++- tests/session.scope.test.ts | 105 ++++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 tests/session.scope.test.ts diff --git a/README.md b/README.md index d60b03b..b13e424 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ You can set up tool hooks to only trigger on specific arguments via `when.toolAr ## Features - Tool hooks (`before`/`after`) and session hooks (`start`/`idle`) via simple JSON/YAML frontmatter config + - Session hooks target parent sessions by default; opt into child or all sessions with `"when": { "event": "session.idle", "sessionScope": "child" }` or `"sessionScope": "any"`. - Hooks are **non-blocking**: failures don’t crash the session/tool. - Commands run **sequentially**, even if earlier ones fail. - Inject bash output into context with `inject` and notify user with `toast` diff --git a/src/executor.ts b/src/executor.ts index d25d5b7..4d2cca8 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -41,7 +41,11 @@ 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 + sessionScope: "parent" | "child" | undefined + } ): SessionHook[] => { return hooks.filter((hook) => { // Normalize session.start to session.created @@ -59,6 +63,9 @@ export const filterSessionHooks = ( return false } + const hookScope = hook.when.sessionScope ?? "parent" + if (hookScope !== "any" && hookScope !== criteria.sessionScope) return false + return matches(hook.when.agent, criteria.agent) }) } diff --git a/src/index.ts b/src/index.ts index da99b86..3ab0c6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,6 +122,21 @@ const deleteToolArgs = (callId: string | undefined): void => { toolCallArgsCache.delete(callId) } +const getSessionScope = async ( + sessionId: string, + client: OpencodeClient, +): Promise<"parent" | "child" | undefined> => { + try { + const result = await client.session.get({ path: { id: sessionId } }) + if (!result.data) return undefined + return result.data.parentID === undefined ? "parent" : "child" + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.debug(`Could not determine scope for session ${sessionId}: ${message}`) + return undefined + } +} + /** @@ -148,10 +163,12 @@ const handleSessionEvent = async ( const markdownConfig = { tool: [], session: [] } const { config: mergedConfig } = mergeConfigs(globalConfig, markdownConfig) + const sessionScope = await getSessionScope(sessionId, client) const matchedHooks = filterSessionHooks(mergedConfig.session || [], { event: eventType, agent, + sessionScope, }) logger.debug( diff --git a/src/schemas.ts b/src/schemas.ts index 65959b3..ac6ef24 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -27,6 +27,7 @@ const PhaseSchema = z.enum(["before", "after"]); * Note: "session.start" maps to "session.created" internally */ const SessionEventSchema = z.enum(["session.created", "session.idle", "session.end", "session.start"]); +const SessionScopeSchema = z.enum(["parent", "child", "any"]); // ============================================================================ // TOOL HOOK SCHEMAS @@ -97,6 +98,7 @@ export const ToolHookSchema = z.object({ const SessionHookWhenSchema = z.object({ event: SessionEventSchema, agent: StringOrArray.optional(), + sessionScope: SessionScopeSchema.default("parent"), }); /** diff --git a/src/types/hooks.ts b/src/types/hooks.ts index ee51200..5734faf 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -168,7 +168,7 @@ export interface SessionHook { * Matching conditions for session hooks * * All specified conditions must match for the hook to execute. - * Omitted fields default to matching all values (wildcard behavior). + * Omitted sessionScope defaults to matching parent sessions. */ export interface SessionHookWhen { /** @@ -182,6 +182,11 @@ export interface SessionHookWhen { * Omitted: defaults to "*" in global config, "this agent" in markdown. */ agent?: string | string[] + + /** + * Session hierarchy to match. Defaults to "parent"; use "any" to match all sessions. + */ + sessionScope?: "parent" | "child" | "any" } /** diff --git a/tests/session.scope.test.ts b/tests/session.scope.test.ts new file mode 100644 index 0000000..c9b428c --- /dev/null +++ b/tests/session.scope.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { filterSessionHooks } from "../src/executor"; +import { parseSessionHook } from "../src/schemas"; + +const ORIGINAL_CWD = process.cwd(); + +const sessionHook = (id: string, sessionScope?: "parent" | "child" | "any") => + parseSessionHook({ + id, + when: { event: "session.idle", ...(sessionScope ? { sessionScope } : {}) }, + inject: id, + })!; + +describe("session hook scope", () => { + it("defaults parsed session hooks to parent scope and accepts child and any", () => { + expect(sessionHook("default").when.sessionScope).toBe("parent"); + expect(sessionHook("child", "child").when.sessionScope).toBe("child"); + expect(sessionHook("any", "any").when.sessionScope).toBe("any"); + expect(parseSessionHook({ + id: "invalid", + when: { event: "session.idle", sessionScope: "all" }, + inject: "invalid", + })).toBeNull(); + }); + + it("matches parent, child, any, and unknown session identities deterministically", () => { + const hooks = [sessionHook("default"), sessionHook("child", "child"), sessionHook("any", "any")]; + + expect(filterSessionHooks(hooks, { + event: "session.idle", + agent: undefined, + sessionScope: "parent", + }).map((hook) => hook.id)).toEqual(["default", "any"]); + expect(filterSessionHooks(hooks, { + event: "session.idle", + agent: undefined, + sessionScope: "child", + }).map((hook) => hook.id)).toEqual(["child", "any"]); + expect(filterSessionHooks(hooks, { + event: "session.idle", + agent: undefined, + sessionScope: undefined, + }).map((hook) => hook.id)).toEqual(["any"]); + }); +}); + +describe("session lifecycle scope adapter", () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "opencode-hooks-session-scope-")); + mkdirSync(join(testDir, ".opencode"), { recursive: true }); + writeFileSync(join(testDir, ".opencode", "command-hooks.jsonc"), JSON.stringify({ + session: [ + { id: "default-parent", when: { event: "session.idle" }, inject: "default-parent" }, + { id: "child-only", when: { event: "session.idle", sessionScope: "child" }, inject: "child-only" }, + { id: "any-session", when: { event: "session.idle", sessionScope: "any" }, inject: "any-session" }, + ], + })); + process.chdir(testDir); + }); + + afterEach(() => { + process.chdir(ORIGINAL_CWD); + rmSync(testDir, { recursive: true, force: true }); + }); + + it("resolves session identity through client.session.get and only lets any hooks run when unknown", async () => { + const promptCalls: string[] = []; + const getCalls: string[] = []; + const client = { + session: { + get: async ({ path }: { path: { id: string } }) => { + getCalls.push(path.id); + if (path.id === "unknown") throw new Error("not found"); + return { data: path.id === "child" ? { parentID: "parent" } : {} }; + }, + promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => { + promptCalls.push(body.parts[0].text); + return {}; + }, + }, + tui: { showToast: async () => ({}) }, + }; + + const { CommandHooksPlugin } = await import("../src/index.js"); + const plugin = await CommandHooksPlugin({ client } as never); + + for (const id of ["parent", "child", "unknown"]) { + await plugin.event?.({ + event: { type: "session.idle", properties: { sessionID: id } }, + } as never); + } + + expect(getCalls).toEqual(["parent", "child", "unknown"]); + expect(promptCalls).toEqual([ + "default-parent", "any-session", + "child-only", "any-session", + "any-session", + ]); + }); +}); From 85a4a7ae1274d354c6f45a180686db361c437272 Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:35:47 -0700 Subject: [PATCH 2/4] Revert "feat: scope session hooks to parent sessions" This reverts commit eb11b80d7eb23160e2f3e901fb9ffd8ec143b971. --- README.md | 1 - src/executor.ts | 9 +--- src/index.ts | 17 ------ src/schemas.ts | 2 - src/types/hooks.ts | 7 +-- tests/session.scope.test.ts | 105 ------------------------------------ 6 files changed, 2 insertions(+), 139 deletions(-) delete mode 100644 tests/session.scope.test.ts diff --git a/README.md b/README.md index b13e424..d60b03b 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,6 @@ You can set up tool hooks to only trigger on specific arguments via `when.toolAr ## Features - Tool hooks (`before`/`after`) and session hooks (`start`/`idle`) via simple JSON/YAML frontmatter config - - Session hooks target parent sessions by default; opt into child or all sessions with `"when": { "event": "session.idle", "sessionScope": "child" }` or `"sessionScope": "any"`. - Hooks are **non-blocking**: failures don’t crash the session/tool. - Commands run **sequentially**, even if earlier ones fail. - Inject bash output into context with `inject` and notify user with `toast` diff --git a/src/executor.ts b/src/executor.ts index 4d2cca8..d25d5b7 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -41,11 +41,7 @@ export const matches = (pattern: string | string[] | undefined, value: string | */ export const filterSessionHooks = ( hooks: SessionHook[], - criteria: { - event: string - agent: string | undefined - sessionScope: "parent" | "child" | undefined - } + criteria: { event: string; agent: string | undefined } ): SessionHook[] => { return hooks.filter((hook) => { // Normalize session.start to session.created @@ -63,9 +59,6 @@ export const filterSessionHooks = ( return false } - const hookScope = hook.when.sessionScope ?? "parent" - if (hookScope !== "any" && hookScope !== criteria.sessionScope) return false - return matches(hook.when.agent, criteria.agent) }) } diff --git a/src/index.ts b/src/index.ts index 3ab0c6b..da99b86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,21 +122,6 @@ const deleteToolArgs = (callId: string | undefined): void => { toolCallArgsCache.delete(callId) } -const getSessionScope = async ( - sessionId: string, - client: OpencodeClient, -): Promise<"parent" | "child" | undefined> => { - try { - const result = await client.session.get({ path: { id: sessionId } }) - if (!result.data) return undefined - return result.data.parentID === undefined ? "parent" : "child" - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.debug(`Could not determine scope for session ${sessionId}: ${message}`) - return undefined - } -} - /** @@ -163,12 +148,10 @@ const handleSessionEvent = async ( const markdownConfig = { tool: [], session: [] } const { config: mergedConfig } = mergeConfigs(globalConfig, markdownConfig) - const sessionScope = await getSessionScope(sessionId, client) const matchedHooks = filterSessionHooks(mergedConfig.session || [], { event: eventType, agent, - sessionScope, }) logger.debug( diff --git a/src/schemas.ts b/src/schemas.ts index ac6ef24..65959b3 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -27,7 +27,6 @@ const PhaseSchema = z.enum(["before", "after"]); * Note: "session.start" maps to "session.created" internally */ const SessionEventSchema = z.enum(["session.created", "session.idle", "session.end", "session.start"]); -const SessionScopeSchema = z.enum(["parent", "child", "any"]); // ============================================================================ // TOOL HOOK SCHEMAS @@ -98,7 +97,6 @@ export const ToolHookSchema = z.object({ const SessionHookWhenSchema = z.object({ event: SessionEventSchema, agent: StringOrArray.optional(), - sessionScope: SessionScopeSchema.default("parent"), }); /** diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 5734faf..ee51200 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -168,7 +168,7 @@ export interface SessionHook { * Matching conditions for session hooks * * All specified conditions must match for the hook to execute. - * Omitted sessionScope defaults to matching parent sessions. + * Omitted fields default to matching all values (wildcard behavior). */ export interface SessionHookWhen { /** @@ -182,11 +182,6 @@ export interface SessionHookWhen { * Omitted: defaults to "*" in global config, "this agent" in markdown. */ agent?: string | string[] - - /** - * Session hierarchy to match. Defaults to "parent"; use "any" to match all sessions. - */ - sessionScope?: "parent" | "child" | "any" } /** diff --git a/tests/session.scope.test.ts b/tests/session.scope.test.ts deleted file mode 100644 index c9b428c..0000000 --- a/tests/session.scope.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { filterSessionHooks } from "../src/executor"; -import { parseSessionHook } from "../src/schemas"; - -const ORIGINAL_CWD = process.cwd(); - -const sessionHook = (id: string, sessionScope?: "parent" | "child" | "any") => - parseSessionHook({ - id, - when: { event: "session.idle", ...(sessionScope ? { sessionScope } : {}) }, - inject: id, - })!; - -describe("session hook scope", () => { - it("defaults parsed session hooks to parent scope and accepts child and any", () => { - expect(sessionHook("default").when.sessionScope).toBe("parent"); - expect(sessionHook("child", "child").when.sessionScope).toBe("child"); - expect(sessionHook("any", "any").when.sessionScope).toBe("any"); - expect(parseSessionHook({ - id: "invalid", - when: { event: "session.idle", sessionScope: "all" }, - inject: "invalid", - })).toBeNull(); - }); - - it("matches parent, child, any, and unknown session identities deterministically", () => { - const hooks = [sessionHook("default"), sessionHook("child", "child"), sessionHook("any", "any")]; - - expect(filterSessionHooks(hooks, { - event: "session.idle", - agent: undefined, - sessionScope: "parent", - }).map((hook) => hook.id)).toEqual(["default", "any"]); - expect(filterSessionHooks(hooks, { - event: "session.idle", - agent: undefined, - sessionScope: "child", - }).map((hook) => hook.id)).toEqual(["child", "any"]); - expect(filterSessionHooks(hooks, { - event: "session.idle", - agent: undefined, - sessionScope: undefined, - }).map((hook) => hook.id)).toEqual(["any"]); - }); -}); - -describe("session lifecycle scope adapter", () => { - let testDir: string; - - beforeEach(() => { - testDir = mkdtempSync(join(tmpdir(), "opencode-hooks-session-scope-")); - mkdirSync(join(testDir, ".opencode"), { recursive: true }); - writeFileSync(join(testDir, ".opencode", "command-hooks.jsonc"), JSON.stringify({ - session: [ - { id: "default-parent", when: { event: "session.idle" }, inject: "default-parent" }, - { id: "child-only", when: { event: "session.idle", sessionScope: "child" }, inject: "child-only" }, - { id: "any-session", when: { event: "session.idle", sessionScope: "any" }, inject: "any-session" }, - ], - })); - process.chdir(testDir); - }); - - afterEach(() => { - process.chdir(ORIGINAL_CWD); - rmSync(testDir, { recursive: true, force: true }); - }); - - it("resolves session identity through client.session.get and only lets any hooks run when unknown", async () => { - const promptCalls: string[] = []; - const getCalls: string[] = []; - const client = { - session: { - get: async ({ path }: { path: { id: string } }) => { - getCalls.push(path.id); - if (path.id === "unknown") throw new Error("not found"); - return { data: path.id === "child" ? { parentID: "parent" } : {} }; - }, - promptAsync: async ({ body }: { body: { parts: Array<{ text: string }> } }) => { - promptCalls.push(body.parts[0].text); - return {}; - }, - }, - tui: { showToast: async () => ({}) }, - }; - - const { CommandHooksPlugin } = await import("../src/index.js"); - const plugin = await CommandHooksPlugin({ client } as never); - - for (const id of ["parent", "child", "unknown"]) { - await plugin.event?.({ - event: { type: "session.idle", properties: { sessionID: id } }, - } as never); - } - - expect(getCalls).toEqual(["parent", "child", "unknown"]); - expect(promptCalls).toEqual([ - "default-parent", "any-session", - "child-only", "any-session", - "any-session", - ]); - }); -}); From c8f805e84724a79e423c73f1d38f3c351e05fcf0 Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:40:21 -0700 Subject: [PATCH 3/4] fix: suppress idle hooks during subagent waits --- README.md | 8 ++- src/executor.ts | 7 ++- src/index.ts | 44 +++++++++++---- src/schemas.ts | 1 + src/subagent-tracker.ts | 41 ++++++++++++++ src/types/hooks.ts | 3 + tests/session.subagent-wait.test.ts | 87 +++++++++++++++++++++++++++++ 7 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 src/subagent-tracker.ts create mode 100644 tests/session.subagent-wait.test.ts 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..b835746 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,7 +299,13 @@ 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) + await handleSessionEvent( + "session.created", + sessionId, + agent, + client as OpencodeClient, + sessionId ? activeSubagents.hasActive(sessionId) : false, + ) } // Handle session.idle event @@ -305,7 +315,13 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => { 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 +339,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 (toolName === "task") activeSubagents.end(sessionId, callId) - if (wasAfterHookProcessed(callId)) { + if (wasAfterHookProcessed(callId)) { logger.debug(`Skipping duplicate after-hook execution for callID: ${callId}`) deleteToolArgs(callId) return @@ -361,6 +379,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 +396,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..34bd952 --- /dev/null +++ b/src/subagent-tracker.ts @@ -0,0 +1,41 @@ +export type ActiveSubagentTracker = { + begin: (sessionId: string, callId?: string) => void + end: (sessionId: string, callId?: string) => void + hasActive: (sessionId: string) => boolean +} + +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) + }, + } +} 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..1f4835e --- /dev/null +++ b/tests/session.subagent-wait.test.ts @@ -0,0 +1,87 @@ +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"]) + }) +}) From b9b7367dda362fecaa77417fcc6dc88f705f24de Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:47:02 -0700 Subject: [PATCH 4/4] fix: clear subagent state for deleted sessions --- src/index.ts | 10 ++++++++-- src/subagent-tracker.ts | 4 ++++ tests/session.subagent-wait.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index b835746..8ee583b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -306,9 +306,15 @@ export const CommandHooksPlugin: Plugin = async ({ client }) => { 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 + // Handle session.idle event if (event.type === "session.idle") { logger.debug("Received session.idle event") diff --git a/src/subagent-tracker.ts b/src/subagent-tracker.ts index 34bd952..6400239 100644 --- a/src/subagent-tracker.ts +++ b/src/subagent-tracker.ts @@ -2,6 +2,7 @@ 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 = { @@ -37,5 +38,8 @@ export const createActiveSubagentTracker = (): ActiveSubagentTracker => { const active = sessions.get(sessionId) return active !== undefined && (active.ids.size > 0 || active.anonymous > 0) }, + clear: sessionId => { + sessions.delete(sessionId) + }, } } diff --git a/tests/session.subagent-wait.test.ts b/tests/session.subagent-wait.test.ts index 1f4835e..3bd34e7 100644 --- a/tests/session.subagent-wait.test.ts +++ b/tests/session.subagent-wait.test.ts @@ -84,4 +84,32 @@ describe("session idle subagent waits", () => { 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"]) + }) })