Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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://`) |

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type Options = {
notifyQuestions: boolean
notifyErrors: boolean
notifyIdle: boolean
notifySubagents: boolean
urgency: Urgency
clickCommand?: string[]
}
Expand All @@ -23,6 +24,7 @@ export const defaults: Options = {
notifyQuestions: true,
notifyErrors: true,
notifyIdle: true,
notifySubagents: false,
urgency: "critical",
}

Expand All @@ -41,6 +43,7 @@ export function parseOptions(raw: unknown): Partial<Options> {
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
}
Expand Down
42 changes: 40 additions & 2 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ export function createEngine(input: EngineInput): Engine {
const suppressIdle = createTracked<true>()
const idleNotified = createTracked<true>()
const lastUser = createTracked<string>()
const children = createTracked<true>()

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)
Expand All @@ -72,13 +79,15 @@ 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)
}

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
Expand All @@ -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, {
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -47,6 +50,7 @@ describe("parseOptions", () => {
notifyQuestions: 1,
notifyErrors: null,
notifyIdle: "false",
notifySubagents: "yes",
urgency: "urgent",
}),
).toEqual({})
Expand Down Expand Up @@ -107,6 +111,7 @@ describe("defaults", () => {
notifyQuestions: true,
notifyErrors: true,
notifyIdle: true,
notifySubagents: false,
urgency: "critical",
})
})
Expand Down
153 changes: 153 additions & 0 deletions test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
})
})
Loading