Skip to content

events: session.idle is declared in the V2 schema but never emitted (completion arrives as session.execution.*) #55358

Open

Description

@salmanovnail

Summary

In OpenCode V2, the public event schema declares session.idle (and session.status, session.error), but the server never publishes those events on the event stream. Runtime completion is emitted only as session.execution.succeeded / session.execution.failed / session.execution.interrupted. Any plugin that subscribes to session.idle (the documented V1 → V2 migration example uses exactly this) silently never fires.

Environment

  • opencode version: 2.0.28
  • OS: Linux 7.0.0-38-generic (Ubuntu, x64)
  • Terminal: TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/bash
  • Install/channel: latest (channel=latest, local=false)
  • Active plugins: cost-logger and notify from ~/.config/opencode/plugins/ (local files); no plugins entries in config

Reproduction

  1. Create a local plugin at ~/.config/opencode/plugins/probe.js that logs every event it receives:

    import fs from "node:fs"
    export default {
      id: "probe",
      setup: async (ctx) => {
        const ac = new AbortController()
        void (async () => {
          for await (const e of ctx.event.subscribe({ signal: ac.signal })) {
            fs.appendFileSync("/tmp/probe.log", e.type + "\n")
          }
        })()
        return () => ac.abort()
      },
    }
  2. Run opencode reload, then trigger a normal session, e.g. opencode run --auto "say hi".

  3. Inspect /tmp/probe.log for session.idle.

The same result occurs when the session is driven from the TUI (interactive), not only via run.

Expected Behavior

session.idle should be delivered to ctx.event.subscribe() subscribers when a session becomes idle, matching the schema and the V1 → V2 migration guidance, which shows if (event.type === "session.idle") console.log("Session completed").

Actual Behavior

session.idle is never delivered. Over multiple runs, the events actually received are:

session.created
session.inbox.enqueued
session.inbox.delivered
session.instructions.updated
session.text.started / .delta / .ended
session.step.started / .streamed / .ended
session.usage.updated
session.execution.started
session.execution.succeeded      <-- completion
session.renamed

For a failed run, completion arrives as session.execution.failed (data carries the error). Neither session.idle, session.status, nor session.error appears.

Binary inspection of the installed build is consistent: session.idle appears only once as a schema/type declaration with no publication site, while session.execution.{started,succeeded,failed,interrupted} are wired into the event flow.

Additional Context

  • Impact: V1-style plugin logic does not port cleanly. The migration guide's own event example (session.idle) is a no-op in V2. Plugins that react to "session finished" (notifications, cost/usage syncing, summaries) never trigger.
  • Workaround: subscribe to session.execution.succeeded / session.execution.failed / session.execution.interrupted instead.
  • The @opencode-ai/sdk published types still contain EventSessionIdle / SessionIdle, so the schema is not the problem — the missing runtime emission is.
  • Environment note: the event stream is authenticated (GET /api/event returns 401 without credentials), so raw curl cannot be used to observe it; ctx.event.subscribe() from a plugin works.

Related but distinct: #55227 (/wait returns when execution starts, not when the session is idle) and #51507 (completion notifications). This report is specifically about session.idle being declared in the V2 event schema but never emitted.

Activity

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I was able to reproduce this on 2.0.28.

    I loaded your probe plugin (ctx.event.subscribe(), logging each e.type) in an isolated project and submitted one prompt in the TUI, with a simulated model replying. The plugin received 25 event types, including session.execution.started and session.execution.succeeded. It never received session.idle or session.status.

    The latest dev (34d8ded) gives the same result. In the code, both events are declared in the V2 event schema, but I couldn't find any place that publishes them. The V2 migration guide still uses session.idle as its example.

    The script below reproduces it. Its log output shows which events arrive, so the recording only shows the TUI run itself. Until this is resolved, subscribing to session.execution.succeeded / failed / interrupted works, as you found.

    Related: #55227 (/wait returns when execution starts, not when the session is idle).

    recording.mp4
    Reproduction script (drive.ts)
    // Repro for #55358: session.idle / session.status never reach plugin event subscribers.
    import { Effect } from "effect"
    import * as fs from "node:fs"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const LOG = "/tmp/opencode/probe-55358.log"
    
    const probe = `import fs from "node:fs"
    export default {
      id: "probe",
      setup: async (ctx) => {
        const ac = new AbortController()
        void (async () => {
          for await (const e of ctx.event.subscribe({ signal: ac.signal })) {
            fs.appendFileSync(${JSON.stringify(LOG)}, e.type + "\\n")
          }
        })()
        return () => ac.abort()
      },
    }
    `
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true, files: { ".opencode/plugins/probe.js": probe } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          fs.rmSync(LOG, { force: true })
          yield* llm.queue(Llm.text("hi there"))
          yield* ui.submit("say hi")
          yield* ui.waitFor("hi there")
          yield* Effect.sleep("4 seconds")
          const types = fs.existsSync(LOG) ? fs.readFileSync(LOG, "utf8").trim().split("\n") : []
          const uniq = [...new Set(types)]
          yield* Effect.log(`events received: ${uniq.join(", ")}`)
          yield* Effect.log(`plugin loaded: ${types.length > 0}`)
          yield* Effect.log(`session.idle received: ${uniq.includes("session.idle")}`)
          yield* Effect.log(`session.status received: ${uniq.includes("session.status")}`)
          yield* Effect.log(`session.execution.succeeded received: ${uniq.includes("session.execution.succeeded")}`)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. holny commented on Oct 14, 2026

    @holny
    Contributor

    I'd like to take this. Confirmed the diagnosis — session.idle is marked deprecated in packages/schema/src/session-status-event.ts, and its replacement session.status is declared but never published anywhere in core/server. The natural emit points are session.execution.started (status busy) and succeeded / failed / interrupted (status idle) in packages/core/src/session/execution.ts. I'll wire those up, add a test asserting the session.status payloads land on the bus, and base against origin/dev.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions