Skip to content

fix(plugin): serialize hooks and enrich tool/turn payloads#1

Open
geekgonecrazy wants to merge 1 commit into
releasefrom
fix/hook-serialization-and-payloads
Open

fix(plugin): serialize hooks and enrich tool/turn payloads#1
geekgonecrazy wants to merge 1 commit into
releasefrom
fix/hook-serialization-and-payloads

Conversation

@geekgonecrazy
Copy link
Copy Markdown

Summary

End-to-end recording was failing for several compounding reasons. After this PR, an opencode session reliably produces one Atomic change per turn with the actual user prompt as the change message, instead of either no change or an auto-generated Add X.tsx summary.

What was broken

  1. Race on session.json. opencode dispatches session.created and chat.message as independent Promise chains. Our per-handler await hook(...) only serialized within a single handler — the session-start and user-prompt atomic processes ran in parallel, and atomic's SessionStore.save has no file lock. Last-writer-wins clobbered first_prompt to null. Reproduced ~1-in-5 with a parallel pair on a clean session.

  2. Wrong turn-end verb. Plugin sent stop on session.idle. atomic's HookType::from_verb maps stop only for Claude Code; OpenCode's TurnEnd verb is after-agent. Net effect: turn never closed, turn_count stayed at 0, record_turn never fired.

  3. Spurious turn-end gate. session.idle can fire before any user prompt. Without a guard, we'd send after-agent for a turn that never started. Now gated on a turnActive flag that flips on chat.message and resets after after-agent.

  4. Missing tool payload fields. tool.execute.before/after only forwarded tool_name and tool_call_id. Atomic uses tool_input (e.g., filePath, content) to attribute changes and produce graph-node summaries like Edit src/foo.tsx instead of generic Edit file. Added tool_input to both, and tool_response (title/output/metadata) to after-tool.

  5. Wrong duration field. Plugin sent duration; atomic's AfterToolInput expects duration_ms. Renamed.

Fix

// Serialize all hook calls so concurrent opencode events don't race on
// session.json — atomic's SessionStore has no file lock, last writer wins.
let hookQueue: Promise<unknown> = Promise.resolve();
async function hook(verb, payload) {
  const json = JSON.stringify(payload);
  const next = hookQueue.then(async () => {
    try { await $`echo ${json} | atomic agent hooks opencode ${verb} 2>/dev/null`.nothrow(); } catch {}
  });
  hookQueue = next.catch(() => {});
  await next;
}

Plus the verb/payload corrections noted above.

opencode.json also gets an explicit "plugin": ["./plugins/atomic-hooks.ts"] entry. opencode auto-discovers plugins from ~/.config/opencode/plugins/ today, so this is a documenting safety net rather than a hard requirement.

Companion fix in atomic CLI

The race manifested here because atomic's SessionStore.save is a non-atomic read-modify-write. The plugin queue patches the symptom for opencode; the underlying issue (other agents could race the same way) is being addressed separately. There's also an in-flight atomic CLI fix for untracked-file detection in record_turn (atomicdotdev/atomic#46) — that PR is required for new-file turns to actually record once the plugin starts feeding atomic correctly.

Verification

Probe project, single turn from prompt change to hello world 4:

Field Before After
phase idle idle
turn_count 0 1
first_prompt null "change to hello world 4"
atomic log (no change for the turn) new change with prompt as commit message

Test plan

  • Plugin loads in fresh opencode session (auto-discovered from ~/.config/opencode/plugins/)
  • Single-turn session: one Atomic change recorded with the user's prompt as the message
  • Multi-turn session: each turn produces its own change; current_prompt cleared between turns
  • Session that ends before any prompt (just opening + closing): no spurious after-agent
  • Bash-only turn (no file edits): records or skips cleanly per atomic's EmptyTurn path

End-to-end recording was failing for several reasons that all surfaced as
"the agent ran a turn but no Atomic change appeared":

1. Concurrent hook calls clobbered session.json. opencode dispatches
   `session.created` and `chat.message` as independent Promise chains, so our
   per-handler `await hook(...)` calls didn't serialize across handlers — the
   `session-start` and `user-prompt` atomic processes raced on session.json,
   and last-writer-wins meant `first_prompt` was often null. Reproduced 1-of-5
   times with a parallel pair on a clean session. Fixed by chaining all hook
   invocations on a single shared promise queue inside the plugin.

2. Wrong turn-end verb. The plugin sent `stop` on `session.idle`, which atomic
   doesn't recognize for OpenCode (`HookType::from_verb` only maps `stop` for
   Claude Code; OpenCode's TurnEnd verb is `after-agent`). Result: turn never
   closed, `turn_count` stayed at 0, no `record_turn` ever fired.

3. Spurious turn-end on bare session-idle. `session.idle` can fire before any
   user prompt; we now gate on a `turnActive` flag that flips on `chat.message`
   and back off when the after-agent fires.

4. Missing tool payload fields. `tool.execute.before/after` only sent
   `tool_name`/`tool_call_id`. Atomic uses `tool_input` (filePath, content) to
   attribute the change to a file and produce graph node summaries like
   `Edit src/foo.tsx` instead of `Edit file`. Added `tool_input` to both, and
   `tool_response` (title/output/metadata) to after-tool.

5. Wrong field name for duration. Plugin sent `duration`; atomic's
   `AfterToolInput` expects `duration_ms`. Renamed.

Also added an explicit `"plugin": ["./plugins/atomic-hooks.ts"]` entry to the
shipped opencode.json. opencode auto-discovers plugins from
`~/.config/opencode/plugins/` today, so this is a no-op safety net documenting
intent — and protects us if auto-discovery behavior changes.

Verified end-to-end on a fresh probe project. A turn from prompt
`change to hello world 4`:

  Before: phase=idle, turn_count=0, first_prompt=null, atomic log empty
  After:  phase=idle, turn_count=1, first_prompt="change to hello world 4",
          atomic log shows new change with the prompt as message
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant