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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 53 additions & 14 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>))
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export function createPlugin(deps?: {
setTimeout?: (fn: () => void, ms?: number) => unknown
clearTimeout?: (id: unknown) => void
activate?: (target: ActivateTarget) => void
listSessions?: () => Promise<Array<{ id?: string; parentID?: string; parent_id?: string; title?: string }>>
}): Plugin {
return async ({ project, directory }, options) => {
return async ({ project, directory, client }, options) => {
const config = {
...defaults,
...(deps?.loadConfig ?? loadFileConfig)(),
Expand All @@ -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 })
Expand Down
160 changes: 160 additions & 0 deletions test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }])
})
})
44 changes: 44 additions & 0 deletions test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading