From 98d1d2ff39eb0ce929ddc4eb706b3649066e4eca Mon Sep 17 00:00:00 2001 From: Gabriel Parrot Date: Sat, 22 Aug 2026 15:09:46 -0400 Subject: [PATCH] Skip child idle when create was missed Learn children from durable event names, parent_id aliases, Task metadata, and (@agent subagent) titles. Hydrate session.list() before the event hook so resume/restart does not fail-open a child idle. Parent idle and unknown-session fail-open unchanged. --- CHANGELOG.md | 2 + README.md | 2 +- src/engine.ts | 67 +++++++++++++++---- src/index.ts | 19 +++++- test/engine.test.ts | 160 ++++++++++++++++++++++++++++++++++++++++++++ test/plugin.test.ts | 44 ++++++++++++ 6 files changed, 278 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c48bb20..12c3f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Learn child sessions from Task metadata, `parent_id`, subagent titles, durable event types, and the session list on start so child idle stays silent + ## 0.3.0 - Skip child / subagent session notifications by default; `notifySubagents: true` restores them diff --git a/README.md b/README.md index 97a8294..e50b718 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Do not copy only `src/index.ts` into `~/.config/opencode/plugins/` — the plugi 3. If the timer fires, the request is still waiting on you, so a notification is sent. 4. `MessageAbortedError` is ignored. It is not an `opencode error` popup. 5. After a user message or `session.status` busy, `session.status` idle / `session.idle` sends `opencode idle`. ESC, a real error, or an idle with no prior turn stays silent. Title or background work does not retract that popup or send a second one. A new user message starts the next turn. -6. Child sessions (`Session.parentID` set) are skipped by default. Parent idle still notifies when the parent finishes. +6. Child sessions (`Session.parentID` set, Task metadata, or a `(@… subagent)` title) are skipped by default. Existing children are hydrated from the session list on start. Parent idle still notifies when the parent finishes. 7. Clicking a popup focuses Zed (`zed://`), using the GNOME/Wayland activation token when the compositor sends one. It does not open `zed://agent`, which would start a new thread. That covers `opencode --auto`, the TUI auto-approve toggle, and any other path that replies before you need to look. diff --git a/src/engine.ts b/src/engine.ts index 3e22e59..1cc9f90 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -36,6 +36,35 @@ export function sessionIdOf(props: { sessionID?: string } | object) { return typeof sessionID === "string" && sessionID ? sessionID : undefined } +function eventKind(type: string) { + return type.replace(/\.\d+$/, "") +} + +function nonEmpty(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} + +function sessionIdFrom(props: { sessionID?: unknown; info?: { id?: unknown } }) { + return nonEmpty(props.info?.id) ?? nonEmpty(props.sessionID) +} + +function parentIdFrom(props: { + parentID?: unknown + parent_id?: unknown + info?: { parentID?: unknown; parent_id?: unknown; title?: unknown } +}) { + return ( + nonEmpty(props.info?.parentID) ?? + nonEmpty(props.info?.parent_id) ?? + nonEmpty(props.parentID) ?? + nonEmpty(props.parent_id) + ) +} + +function titleLooksChild(title: unknown) { + return typeof title === "string" && / \(@\S+ subagent\)/.test(title) +} + export function createEngine(input: EngineInput): Engine { const setTimer = input.setTimeout ?? ((fn: () => void, ms?: number) => setTimeout(fn, ms)) const clearTimer = input.clearTimeout ?? ((id: unknown) => clearTimeout(id as ReturnType)) @@ -55,6 +84,11 @@ export function createEngine(input: EngineInput): Engine { return children.has(sessionId) } + function rememberChild(id?: string, parentID?: string, title?: unknown) { + if (!id) return + if (parentID || titleLooksChild(title)) children.set(id, true) + } + function cancel(id: string) { const timer = pending.get(id) if (timer) clearTimer(timer) @@ -121,26 +155,23 @@ export function createEngine(input: EngineInput): Engine { return { handle(event) { - const type = event.type + const type = eventKind(event.type) const properties = event.properties ?? {} if (type === "session.created" || type === "session.updated") { - const props = properties as { sessionID?: string; info?: { id?: string; parentID?: string } } - const id = - typeof props.info?.id === "string" && props.info.id - ? props.info.id - : sessionIdOf(props) - const parentID = props.info?.parentID - if (id && typeof parentID === "string" && parentID) children.set(id, true) + const props = properties as { + sessionID?: unknown + parentID?: unknown + parent_id?: unknown + info?: { id?: unknown; parentID?: unknown; parent_id?: unknown; title?: unknown } + } + rememberChild(sessionIdFrom(props), parentIdFrom(props), props.info?.title) return } if (type === "session.deleted") { - const props = properties as { sessionID?: string; info?: { id?: string } } - const id = - typeof props.info?.id === "string" && props.info.id - ? props.info.id - : sessionIdOf(props) + const props = properties as { sessionID?: unknown; info?: { id?: unknown } } + const id = sessionIdFrom(props) if (id) children.delete(id) return } @@ -243,12 +274,20 @@ export function createEngine(input: EngineInput): Engine { tool?: string id?: string sessionID?: string - state?: { status?: string } + metadata?: { parentSessionId?: unknown; sessionId?: unknown } + state?: { status?: string; metadata?: { parentSessionId?: unknown; sessionId?: unknown } } input?: { questions?: Array<{ question?: string }> } } } ).part if (part?.type !== "tool") return + if (part.tool?.toLowerCase() === "task") { + for (const meta of [part.metadata, part.state?.metadata]) { + const parentSessionId = nonEmpty(meta?.parentSessionId) + const sessionId = nonEmpty(meta?.sessionId) + if (parentSessionId && sessionId) children.set(sessionId, true) + } + } if (part.tool?.toLowerCase() !== "askuserquestion") return if (part.state?.status !== "pending") return if (!input.options.notifyQuestions) return diff --git a/src/index.ts b/src/index.ts index 653ecbc..599f984 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,9 @@ export function createPlugin(deps?: { setTimeout?: (fn: () => void, ms?: number) => unknown clearTimeout?: (id: unknown) => void activate?: (target: ActivateTarget) => void + listSessions?: () => Promise> }): Plugin { - return async ({ project, directory }, options) => { + return async ({ project, directory, client }, options) => { const config = { ...defaults, ...(deps?.loadConfig ?? loadFileConfig)(), @@ -39,6 +40,22 @@ export function createPlugin(deps?: { setTimeout: deps?.setTimeout, clearTimeout: deps?.clearTimeout, }) + const list = + deps?.listSessions ?? + (client?.session?.list + ? async () => { + const r = await client.session.list() + const rows = Array.isArray(r) ? r : r?.data ?? [] + return Array.isArray(rows) ? rows : [] + } + : undefined) + if (list) { + try { + for (const info of await list()) engine.handle({ type: "session.updated", properties: { info } }) + } catch { + /* fail open */ + } + } return { event: async ({ event }) => { engine.handle(event as { type: string; properties?: unknown }) diff --git a/test/engine.test.ts b/test/engine.test.ts index 1829920..ba830b7 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -525,4 +525,164 @@ describe("createEngine", () => { advance(250) expect(sent).toEqual([]) }) + + test("idle before created still fail-open", () => { + const { engine, sent } = setup() + engine.handle({ type: "session.status", properties: { sessionID: "ses_unknown", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_unknown" } }) + expect(sent).toEqual([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_unknown" }]) + }) + + test("stays silent on session.created.1 with parentID", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created.1", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle.1", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("stays silent on session.updated.1 with parentID", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.updated.1", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("tracks a child from parent_id on info", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", parent_id: "ses_parent" } }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("tracks a child from top-level parentID", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { sessionID: "ses_child", parentID: "ses_parent" }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("tracks a child from Task state metadata", () => { + const { engine, sent } = setup() + engine.handle({ + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "task", + state: { metadata: { parentSessionId: "ses_parent", sessionId: "ses_child" } }, + }, + }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("tracks a child from Task part.metadata", () => { + const { engine, sent } = setup() + engine.handle({ + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "task", + metadata: { parentSessionId: "ses_parent", sessionId: "ses_child" }, + }, + }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("tracks a child from a subagent title", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", title: "Explore (@explore subagent)" } }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("does not mark a parent from a title without the subagent suffix", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_parent", title: "Implement the feature" } }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_parent", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_parent" } }) + expect(sent).toEqual([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_parent" }]) + }) + + test("notifies only the parent when parallel children go idle", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_c1", parentID: "ses_parent" } }, + }) + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_c2", parentID: "ses_parent" } }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_c1", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_c1" } }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_c2", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_c2" } }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_parent", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_parent" } }) + expect(sent).toEqual([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_parent" }]) + }) + + test("tracks a resumed child from Task metadata without created", () => { + const { engine, sent } = setup() + engine.handle({ + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "task", + state: { metadata: { parentSessionId: "ses_parent", sessionId: "ses_child" } }, + }, + }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("notifies a child learned from Task metadata when notifySubagents is true", () => { + const { engine, sent } = setup({ notifySubagents: true }) + engine.handle({ + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "task", + state: { metadata: { parentSessionId: "ses_parent", sessionId: "ses_child" } }, + }, + }, + }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_child" }]) + }) }) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index e088ef8..955335f 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -88,6 +88,50 @@ describe("createPlugin", () => { }) expect(sent).toEqual([]) }) + + test("hydrates children from listSessions before events", async () => { + const sent: Array<{ title: string }> = [] + const plugin = createPlugin({ + loadConfig: () => ({}), + send(title) { + sent.push({ title }) + return 1 + }, + close() {}, + listSessions: async () => [{ id: "ses_child", parentID: "ses_parent" }], + }) + const hooks = await plugin(input({ name: "demo" }), {}) + await hooks.event?.({ + event: { type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } } as never, + }) + await hooks.event?.({ + event: { type: "session.idle", properties: { sessionID: "ses_child" } } as never, + }) + expect(sent).toEqual([]) + }) + + test("listSessions throw stays fail-open", async () => { + const sent: Array<{ title: string }> = [] + const plugin = createPlugin({ + loadConfig: () => ({}), + send(title) { + sent.push({ title }) + return 1 + }, + close() {}, + listSessions: async () => { + throw new Error("nope") + }, + }) + const hooks = await plugin(input({ name: "demo" }), {}) + await hooks.event?.({ + event: { type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } } as never, + }) + await hooks.event?.({ + event: { type: "session.idle", properties: { sessionID: "ses_1" } } as never, + }) + expect(sent).toEqual([{ title: "opencode idle" }]) + }) }) describe("default export", () => {