Skip to content

Add Opencode hooks to nitro agent - #10366

Merged
michaelstaib merged 249 commits into
mainfrom
mst/opencode
Sep 21, 2026
Merged

michaelstaib merged 249 commits into
mainfrom
mst/opencode

Conversation

@michaelstaib

Copy link
Copy Markdown
Member

No description provided.

The first-prompt announcement and idle-push-per-transition gate no longer
reserve reserved sentinel ids on the mail delivery ledger (session_deliveries
stays real-mail-only). Both are now atomically armed/claimed via two new
agent_sessions columns and IAgentSessionRegistry methods, so a Nitro-pushed
chat message can skip rearming without touching the ledger at all.
Nitro's push side is expected (hc-10-5n6.2) to prepend a reserved text
prefix to a delivered message instead of tagging it with metadata; the
generated shim now detects and strips that exact prefix from output.parts
(opencode delivers the model-bound message there, not input.parts) and
sets nitroPushed on the hook payload. Adds a generated-JavaScript
regression, executed under Node, guarding the output.parts requirement.
… command group

Wires the opencode hook handler landed in hc-10-nds.1 into a process
entry point: OpencodeHookExecutor mirrors ClaudeHookExecutor and
CodexHookExecutor's fail-open envelope (stdin/stdout JSON, 10s entry
timeout, NITRO_HOOK_SUPPRESS kill switch, neutral {} on every failure
except a schema mismatch, which reports to stderr with exit 1), and
the hidden `nitro agent hook opencode` command group adds the
session-created, chat-message, session-idle, and session-deleted
leaf commands the generated shim invokes.
Adds the opencode-server branch to ActorWakeDispatcher and
PingSessionExecutor: wake pushes the mail digest as a real prompt
(prefixed with the reserved nitro push marker so the shim recognizes
and skips it) via OpencodeServerClient.PushMessageAsync, guarded by
the session registry's idle-push claim so at most one push fires per
idle transition and mail during a nitro-pushed turn waits for the
next one; when there is nothing left to deliver, a health-only ping
via PushAsync's sibling verifies the endpoint instead of a blind ok.
Registers IOpencodeServerClient additively in
ServiceCollectionExtensions. Existing claude-peer and codex-thread
branches are untouched.

Ref: hc-10-5n6.2
Move the opencode idle-push claim so it only spends the session's
one-shot flag once the gate reservation is already held, instead of
before it: a busy gate or a dropped capacity slot no longer burns the
claim for nothing. Rearm the claim whenever an attempt did not
actually deliver (a terminal failure, an access-denied offer, or a
health-only ping that found no digest left to push), so a transport
failure or a benign mail-already-read race no longer strands the
session waiting on a human prompt. PingSessionExecutor now tags a
health-only ping's outcome with a dedicated detail constant so the
dispatcher can tell it apart from a delivered push. Name the opencode
switch arm explicitly instead of falling through a wildcard.

Covers: a busy session gate never spends the idle-push claim, an
outright transport failure rearms it, and a health-only ping (no
mail left to push) rearms it too.
…ation

DispatchTargetAsync could strand an OpencodeServer session's one-shot
idle-push claim when the dispatch was cancelled between a successful
ClaimIdlePushAsync and the outcome-based rearm (a lost lease renewal or
caller shutdown mid-transport). Track the claim with idlePushClaimed/
idlePushSettled flags and hand it back from the inner finally whenever
a claimed attempt never reached the existing outcome-based decision.
SessionDeliveryLedger's reserving INSERT is already conditioned on the
session row existing, so the FK-violation catch in
OpencodeHookHandler.ReserveAsync (and the SessionRemovedDuringDeliveryException
it fed) could never fire; a deleted session already reserves nothing via
the normal empty-result path. Also documents that dryRun has no effect
in this handler (opencode has no session-file side channel to skip).

Findings F1 and F6 from hc-10-nds.1's review comment.
…stract

Adds back ISessionDeliveryLedger.ReserveAsync(harness, sessionId, ...)
as an additive overload with its original byte-identical SQL, and
reverts ClaudeHookHandler/CodexHookHandler and their test doubles to
call it, so the opencode host-aware overload no longer touches the
shipped Claude/Codex paths beyond the additive abstraction.

Also makes IAgentSessionRegistry's Arm/Claim announcement and idle-push
members abstract instead of default no-ops, and implements them on the
fake registry and every test decorator, so an implementation that does
not support the markers fails to compile rather than silently doing
nothing.

Findings F4 and F5 from hc-10-nds.1's review comment.
Seeds a raw v12-shaped agent_sessions table, predating the
announcement_pending and idle_push_armed columns, and asserts
InitializeAsync adds both defaulted to 0 without losing the existing
row.

Finding F2 from hc-10-nds.1's review comment.
Adds a chat-message-pushed.json fixture with nitroPushed: true and a
test asserting OpencodeHookExecutor deserializes it onto
OpencodeHookPayload.NitroPushed.

Finding F7 from hc-10-nds.1's review comment.
OpencodeHookHandler.HandleSessionIdleAsync no longer calls
ClaimIdlePushAsync or reserves a gate-channel delivery: the dispatcher
already claims the flag after its own gate reservation and rearms it
on every non-delivering path, so an idle event that beats the daemon
no longer spends the one-shot claim with nothing pushed. The event now
only touches presence/heartbeat, since the shim discards its response
either way. OpencodeHookOutcome.IdleDelivery is removed as dead
plumbing along with it.

Per the hc-10-5n6.2 planner ruling (option A).
Four minors from the hc-10-5n6.2 review cycle 3:
- Split the two ActorWakeDispatcherTests exceeding the 5-Assert limit
  into separate push/suppress and offer/deliver tests.
- Classify 'idle-not-armed' as a transient offer reason in
  MailWakeDaemonRetryPolicy.IsTransientOffer, matching busy/
  capacity-dropped/access-denied.
- Deduplicate PingSessionExecutor.ExecuteOpencodeServerAsync against
  ExecuteAsync via a shared helper, keeping every branch's existing
  behavior byte-identical.
- Persist the opencode health-only ping detail only when it actually
  changed from the row's own last recorded state, instead of
  rewriting it on every idle poll.
ActorWakeDispatcher minted a fresh pingAttemptId for every dispatch but
never stamped it onto agent_sessions.last_ping_attempt, so the executor's
later WritePingResultAsync call (conditioned on that column) always
matched zero rows: no ping result was ever durably recorded via the wake
path, for any harness. Stamp the attempt through
IAgentSessionRegistry.TryClaimPingCooldownAsync right before invoking the
executor, mirroring the retired manual ping command's own sequence, so
the write actually lands.
Adds a regression proving ActorWakeDispatcher, wired to the real
PingSessionExecutor, durably stamps last_ping_attempt and writes
last_ping_result on the agent_sessions row - the gap the wake-path fix
closes.
Drops the persist-only-when-changed gate in PingSessionExecutor: the
prior TryClaimPingCooldownAsync claim already nulls last_ping_result
and last_ping_detail before every attempt, so skipping the write for a
repeated health-only outcome left the row NULL on alternate wakes
instead of restoring it. Every health-only ping now writes back like
any other attempt. Also corrects the ActorWakeDispatcher comment
describing TryClaimPingCooldownAsync's false-return condition to match
its actual predicate (row missing or last_ping_at ahead of now).
Verification:
- dotnet format /Users/michael/local/hc-10/.claude/worktrees/nora-tui-test-setups/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --no-restore --include [the three touched TUI test files], then --verify-no-changes: passed.
- dotnet build /Users/michael/local/hc-10/.claude/worktrees/nora-tui-test-setups/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --framework net10.0 --no-incremental -m:2 -p:EnforceCodeStyleInBuild=true: passed (four existing ASPDEPR warnings).
- dotnet test /Users/michael/local/hc-10/.claude/worktrees/nora-tui-test-setups/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --framework net10.0 --no-build -p:EnforceCodeStyleInBuild=true -- --filter-class "*MailStateTests*" --filter-class "*MailModeTests*" --filter-class "*FormTests*" --filter-class "*TuiEffectQueueTests*" --filter-class "*TaskEditorFormTests*" --filter-class "*TuiShellQuitGateTests*": passed 231/231.
- Restored TaskEditorFormTests proof mutations fail loudly with Missing Title field frame header., Missing Title field frame footer., and Missing Save button frame.

Gap: the orchestrator-owned eight full-suite runs under natural load were not run.
Limit each field footer lookup to rows before the next field header, so a later field cannot satisfy the named footer guard.

Observed isolated proof: temporarily removed only lines[titleFooterIndex], the first footer after the Title header, from the Title iteration's fresh rendered rows. The mutation left every other row intact, including the following Status header, content, and footer. The focused test exited 2 with 20 passed and one failure, 'Missing Title field frame footer.' The temporary mutation was restored before the unchanged-editor rerun.

Validation: dotnet test /Users/michael/local/hc-10/.claude/worktrees/nora-tui-test-setups/src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class '*TaskEditorFormTests*' passed 21/21 after restoration and again after formatting. Scoped dotnet-format write and --verify-no-changes passed for TaskEditorFormTests.cs.
Restore the prior above-first selection bias so a two-row window around a middle selection renders [selected-1, selected].

Add an exact rendered-window snapshot, which distinguishes that contract from below-first rendering.

Verification:
- FAIL (opposite below-first direction): dotnet test /Users/michael/local/hc-10/.claude/worktrees/nora-list-window-bias/src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class "*EditableListFieldTests*"; 13 total, 1 failed, expected first/selected but rendered selected/third.
- PASS: dotnet test /Users/michael/local/hc-10/.claude/worktrees/nora-list-window-bias/src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class "*EditableListFieldTests*" --filter-class "*FormTests*"; 93 passed.
- PASS: dotnet build /Users/michael/local/hc-10/.claude/worktrees/nora-list-window-bias/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj -p:EnforceCodeStyleInBuild=true --no-incremental -m:2; Build succeeded.

Limitations: Full suite not run, integration is orchestrator-owned.
Stop rendering row segments after a narrow renderer slices or drops one.

Fail-first against bf0892a:
dotnet test src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class '*TaskBadgeTests*' --filter-class '*TaskDetail*'\nfailed 2 of 83: TaskDetail expected "  ○ …", actual "  ○  …"; TaskBadge expected "  ○ […", actual "  ○ [ …".\n\nRestored green with the same focused command: 83 passed, 0 failed.\ndotnet build src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj -p:EnforceCodeStyleInBuild=true --no-incremental -m:2 passed for all target frameworks.\ndotnet format write and --verify-no-changes passed for all four touched C# files.
Regression evidence:
- Red: dotnet test /Users/michael/local/hc-10/.claude/worktrees/nora-codex-sidecar-snapshot/src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class '*CodexHooksInstallerServiceTests*' failed 1, expected ExitException but no exception was thrown.
- Green: dotnet test /Users/michael/local/hc-10/.claude/worktrees/nora-codex-sidecar-snapshot/src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class '*CodexHooksSidecarStoreTests*' --filter-class '*CodexHooksInstallerServiceTests*' passed 9.
- Build: dotnet build /Users/michael/local/hc-10/.claude/worktrees/nora-codex-sidecar-snapshot/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj -p:EnforceCodeStyleInBuild=true --no-incremental -m:2 succeeded with 0 errors.

The read, compare, and write remain non-atomic. hc-10-wff owns fencing that window.
Hold a per-sidecar exclusive file handle across the re-read, hash comparison, and atomic replacement. Contention retries briefly before reporting an explicit concurrent-process conflict, while persistent lock files avoid deletion races.\n\nVerification:\n- dotnet test src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class '*SidecarStoreTests*' --filter-class '*HooksInstallerServiceTests*' (32 passed)\n- dotnet build src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj -p:EnforceCodeStyleInBuild=true --no-incremental -m:2 (passed)
Fence Codex sidecar reread, snapshot comparison, and replacement with the same persistent adjacent exclusive lock-file pattern used by the other hook stores. Contention retries briefly with cancellation support, then reports a filename-bearing conflict.\n\nAdd deterministic Codex and Opencode concurrent-store coverage using ConcurrentTestHarness, independent stores, a shared snapshot, and a first replacement held through the contender's bounded exhaustion. The tests assert one winner, the loser conflict, and the exact persisted content. Add cancellation, stale rejection, and lock-release checks after rejected, successful, and replacement-exception paths.\n\nVerification:\n- dotnet format src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --no-restore --include <five touched files> (write and --verify-no-changes passed; workspace-load warnings only)\n- dotnet test src/Nitro/CommandLine/test/CommandLine.Tests --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class '*SidecarStoreTests*' --filter-class '*HooksInstallerServiceTests*' (41 passed)\n- dotnet build src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj -p:EnforceCodeStyleInBuild=true --no-incremental -m:2 (passed; 8 pre-existing ASP deprecation warnings)\n\nEvidence limits:\n- The contention tests use two independently created stores in one process. They demonstrate contention through the actual File.Open(..., FileShare.None) lock handles on this Darwin runtime, but do not observe a second process. The cross-process guarantee rests on the OS-handle semantics of that API, not on this two-thread observation.\n- No ready managed child-process sidecar fixture exists. Adding one would require isolated worker dispatch plus snapshot/start/result coordination and child cleanup, so it was not cheap enough for this correction.\n- No denied old/unfenced Claude experiment was run. The full integrated suite remains for the orchestrator.
Preserve the final File.Open IOException after bounded lock acquisition,
including both the sidecar and write-lock paths in the ExitException.
This avoids claiming that another nitro process owns every acquisition
failure. Exercise a real File.Open failure after a test-local sidecar
directory disappears, and update the contention diagnostics. Rename the
nine candidate tests to the repository naming convention.

Review dispositions:
- F1 DISMISSED (planner-adjudicated scope/pre-existing/preference).
- F2 DISMISSED (planner-adjudicated scope/pre-existing/preference).
- F3 DISMISSED (planner-adjudicated scope/pre-existing/preference).
- F4 FIXED: bounded lock exhaustion now exposes the actual IOException.
- F5 DISMISSED (planner-adjudicated scope/pre-existing/preference).
- F6 DISMISSED (planner-adjudicated scope/pre-existing/preference).
- F7 DISMISSED (planner-adjudicated scope/pre-existing/preference).
- F8 DEFERRED: the duplicate wrapper remains untouched.
- F9 FIXED: failures use ThrowHelper.Exit.
- F10 FIXED: the nine scoped test names follow Method_Should_Outcome_When_Condition.

Verification observed in this worktree:
- dotnet format src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --no-restore --include <the seven changed files>
  completed, with workspace-load warnings.
- dotnet format src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --no-restore --verify-no-changes --include <the seven changed files>
  completed, with workspace-load warnings.
- dotnet test src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --framework net10.0 -p:EnforceCodeStyleInBuild=true -- --filter-class '*SidecarStoreTests*' --filter-class '*HooksInstallerServiceTests*'
  passed: 44 total, 44 succeeded, 0 failed, 0 skipped; 1m 41.593s.
- dotnet build src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj --no-incremental -m:2 -p:EnforceCodeStyleInBuild=true
  succeeded: 0 errors, 8 warnings.

Limits: the in-process writers demonstrate the OS lock acquisition and
cooperating-writer behavior, not separate-process exclusion. The Unix
advisory-lock guarantee assumes a stable inode and cooperating writers.
No denied old-source Claude fence-removal proof was retried, and no claim
is made about the prior MSB3030 failure cause.
@michaelstaib
michaelstaib merged commit ccb3e8d into main Sep 21, 2026
8 of 9 checks passed
@michaelstaib
michaelstaib deleted the mst/opencode branch September 21, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📚 documentation This issue is about working on our documentation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants