diff --git a/CHANGELOG.md b/CHANGELOG.md index a668fc9..8f4d260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Skip child / subagent session notifications by default; `notifySubagents: true` restores them - Notify when an agent finishes (`session.status` idle / `session.idle` → `opencode idle`) - Stay silent on idle after ESC / `MessageAbortedError`, a real error, or an idle with no prior busy turn - Do not retract or re-send an idle popup when title or background work sets the session busy diff --git a/README.md b/README.md index 9002c6f..fa06389 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Linux, macOS, and Windows. Use **0.1.1 or later** — 0.1.0 does not load. | Session error | `opencode error` | | Agent finished (`session.status` idle) | `opencode idle` | | ESC / `MessageAbortedError` | none | +| Subagent / child-session events | none unless `notifySubagents` | A request popup already on screen is retracted when `permission.replied` arrives (Linux and Windows). macOS Notification Center cannot dismiss a posted banner from a script. @@ -76,7 +77,8 @@ 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. 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. +6. Child sessions (`Session.parentID` set) are skipped by default. 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. @@ -91,6 +93,7 @@ Optional `~/.config/opencode/opencode-smart-notify.json`. Plugin tuple options i | `notifyQuestions` | `true` | `askuserquestion` | | `notifyErrors` | `true` | Session errors (not cancel) | | `notifyIdle` | `true` | Agent finished (`session.status` idle) | +| `notifySubagents` | `false` | Task / child-session events | | `urgency` | `"critical"` | `low`, `normal`, or `critical` | | `clickCommand` | *(auto)* | Argv run on click. `{sessionId}` is substituted. Default: focus Zed (`zed://`) | diff --git a/ROADMAP.md b/ROADMAP.md index 356ec4d..abdf6c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ Parity with what people expect from a notify plugin, still Linux-only. - [x] Session done / idle notification (the “look back” signal) - [ ] Optional suppress-when-focused (fail open if focus cannot be detected) - [ ] Close an already-shown notification when the OpenCode terminal becomes focused or the prompt is no longer waiting -- [ ] Skip or separately gate subagent / child-session events +- [x] Skip or separately gate subagent / child-session events - [ ] Sanitize notification bodies (truncate, strip likely secrets) so lock-screen / notification history leak less - [ ] Use `spawn` instead of `spawnSync` so the event hook does not block - [ ] Stop marking every event `critical`; map urgency per event type diff --git a/src/config.ts b/src/config.ts index df85a34..df1bb80 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ export type Options = { notifyQuestions: boolean notifyErrors: boolean notifyIdle: boolean + notifySubagents: boolean urgency: Urgency clickCommand?: string[] } @@ -23,6 +24,7 @@ export const defaults: Options = { notifyQuestions: true, notifyErrors: true, notifyIdle: true, + notifySubagents: false, urgency: "critical", } @@ -41,6 +43,7 @@ export function parseOptions(raw: unknown): Partial { if (typeof o.notifyQuestions === "boolean") out.notifyQuestions = o.notifyQuestions if (typeof o.notifyErrors === "boolean") out.notifyErrors = o.notifyErrors if (typeof o.notifyIdle === "boolean") out.notifyIdle = o.notifyIdle + if (typeof o.notifySubagents === "boolean") out.notifySubagents = o.notifySubagents if (typeof o.urgency === "string" && (URGENCIES as readonly string[]).includes(o.urgency)) { out.urgency = o.urgency as Urgency } diff --git a/src/engine.ts b/src/engine.ts index 0f5b186..3e22e59 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -47,6 +47,13 @@ export function createEngine(input: EngineInput): Engine { const suppressIdle = createTracked() const idleNotified = createTracked() const lastUser = createTracked() + const children = createTracked() + + function hiddenChild(sessionId?: string) { + if (input.options.notifySubagents) return false + if (!sessionId) return false + return children.has(sessionId) + } function cancel(id: string) { const timer = pending.get(id) @@ -72,6 +79,7 @@ export function createEngine(input: EngineInput): Engine { function markBusy(sessionId?: string) { if (!sessionId) return + if (hiddenChild(sessionId)) return if (idleNotified.has(sessionId) || suppressIdle.has(sessionId)) return active.set(sessionId, true) } @@ -79,6 +87,7 @@ export function createEngine(input: EngineInput): Engine { function notifyIdle(sessionId?: string) { if (!sessionId || !active.has(sessionId)) return active.delete(sessionId) + if (hiddenChild(sessionId)) return if (suppressIdle.has(sessionId)) return if (!input.options.notifyIdle) return if (idleNotified.has(sessionId)) return @@ -98,6 +107,7 @@ export function createEngine(input: EngineInput): Engine { setTimer(() => { pending.delete(id) if (replied.has(id)) return + if (hiddenChild(sessionId)) return remember( id, input.send("opencode request", `${input.projectName}: ${body}`.slice(0, 240), input.options.urgency, { @@ -114,6 +124,27 @@ export function createEngine(input: EngineInput): Engine { const type = 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) + 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) + if (id) children.delete(id) + return + } + if (type === "permission.asked") { const props = properties as { id?: string @@ -125,8 +156,10 @@ export function createEngine(input: EngineInput): Engine { } const id = requestIds(props)[0] if (!id) return + const sessionId = sessionIdOf(props) + if (hiddenChild(sessionId)) return const extra = props.patterns?.join(", ") ?? "" - queue(id, [props.permission ?? "permission", extra].filter(Boolean).join(" "), sessionIdOf(props)) + queue(id, [props.permission ?? "permission", extra].filter(Boolean).join(" "), sessionId) return } @@ -141,7 +174,9 @@ export function createEngine(input: EngineInput): Engine { } const id = requestIds(props)[0] if (!id) return - queue(id, [props.type ?? "permission", props.title ?? ""].filter(Boolean).join(" "), sessionIdOf(props)) + const sessionId = sessionIdOf(props) + if (hiddenChild(sessionId)) return + queue(id, [props.type ?? "permission", props.title ?? ""].filter(Boolean).join(" "), sessionId) return } @@ -161,6 +196,7 @@ export function createEngine(input: EngineInput): Engine { error?: { name?: string; data?: { message?: string; name?: string } } } const sessionId = sessionIdOf(props) + if (hiddenChild(sessionId)) return if (sessionId) suppressIdle.set(sessionId, true) if (isAbortedError(props.error)) return if (!input.options.notifyErrors) return @@ -192,6 +228,7 @@ export function createEngine(input: EngineInput): Engine { const sessionId = sessionIdOf(props) ?? sessionIdOf(props.info ?? {}) const id = props.info?.id if (!sessionId || props.info?.role !== "user" || !id) return + if (hiddenChild(sessionId)) return if (lastUser.get(sessionId) === id) return lastUser.set(sessionId, id) beginTurn(sessionId) @@ -215,6 +252,7 @@ export function createEngine(input: EngineInput): Engine { if (part.tool?.toLowerCase() !== "askuserquestion") return if (part.state?.status !== "pending") return if (!input.options.notifyQuestions) return + if (hiddenChild(sessionIdOf(part))) return const id = part.id ?? "question" if (asked.has(id)) return asked.set(id, true) diff --git a/test/config.test.ts b/test/config.test.ts index 3ebdb7e..1670a08 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -31,13 +31,16 @@ describe("parseOptions", () => { notifyQuestions: false, notifyErrors: false, notifyIdle: false, + notifySubagents: true, }), ).toEqual({ notifyRequests: false, notifyQuestions: false, notifyErrors: false, notifyIdle: false, + notifySubagents: true, }) + expect(parseOptions({ notifySubagents: false })).toEqual({ notifySubagents: false }) }) test("ignores invalid notify flags and urgency", () => { @@ -47,6 +50,7 @@ describe("parseOptions", () => { notifyQuestions: 1, notifyErrors: null, notifyIdle: "false", + notifySubagents: "yes", urgency: "urgent", }), ).toEqual({}) @@ -107,6 +111,7 @@ describe("defaults", () => { notifyQuestions: true, notifyErrors: true, notifyIdle: true, + notifySubagents: false, urgency: "critical", }) }) diff --git a/test/engine.test.ts b/test/engine.test.ts index e5f6c8d..1829920 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -372,4 +372,157 @@ describe("createEngine", () => { expect(sent).toHaveLength(1) expect(closed).toEqual([]) }) + + test("stays silent when a child session goes idle", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + 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" } }) + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "idle" } } }) + expect(sent).toEqual([]) + }) + + test("stays silent on a child session error", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + engine.handle({ + type: "session.error", + properties: { sessionID: "ses_child", error: { name: "UnknownError", data: { message: "boom" } } }, + }) + expect(sent).toEqual([]) + }) + + test("stays silent on a child permission request", () => { + const { engine, sent, advance } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + engine.handle({ + type: "permission.asked", + properties: { id: "p1", permission: "bash", sessionID: "ses_child" }, + }) + advance(250) + expect(sent).toEqual([]) + }) + + test("stays silent on a child question", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + engine.handle({ + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "askuserquestion", + id: "q1", + sessionID: "ses_child", + state: { status: "pending" }, + input: { questions: [{ question: "Ship it?" }] }, + }, + }, + }) + expect(sent).toEqual([]) + }) + + test("still notifies when the parent session goes idle", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + 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" } }) + 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 a child session when notifySubagents is true", () => { + const { engine, sent } = setup({ notifySubagents: true }) + engine.handle({ + type: "session.created", + 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([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_child" }]) + }) + + test("notifies an unknown session (fail open)", () => { + const { engine, sent } = setup() + engine.handle({ type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_1" } }) + expect(sent).toEqual([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_1" }]) + }) + + test("learns a child session before idle and stays silent", () => { + const { engine, sent } = setup() + engine.handle({ type: "session.status", properties: { sessionID: "ses_child", status: { type: "busy" } } }) + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("tracks a child session from session.updated", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.updated", + 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("does not start a turn from a child user message", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + engine.handle({ + type: "message.updated", + properties: { sessionID: "ses_child", info: { id: "msg_1", role: "user" } }, + }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_child" } }) + expect(sent).toEqual([]) + }) + + test("tracks a child session from the v2 created shape", () => { + const { engine, sent } = setup() + engine.handle({ + type: "session.created", + properties: { sessionID: "ses_child", 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("stays silent when a child is learned before a request settles", () => { + const { engine, sent, advance } = setup() + engine.handle({ + type: "permission.asked", + properties: { id: "p1", permission: "bash", sessionID: "ses_child" }, + }) + engine.handle({ + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + }) + advance(250) + expect(sent).toEqual([]) + }) })