$ compare turn-state adapters

Build One Claude Code and Codex State Contract Across macOS and Windows

Sharing source code is optional. Sharing the meaning of each transcript event is not. A status monitor needs one semantic contract even when one app is written in Swift and the other in C#.

Verified against Agent Island v1.7.1 on 2026-07-30. The public stable release contains the macOS and Windows classifiers discussed here. The same release has platform-specific fixtures for Claude and Codex turn events.

Claude and Codex do not speak the same log language

Both tools append JSONL records, but their envelopes and completion markers differ. Claude Code records assistant and user messages. Completion appears in message.stop_reason, with values such as end_turn or stop_sequence. Codex uses event records whose payload types include task_started, turn/started, task_complete, and turn/completed.

A cross-platform monitor cannot expose those provider details to the rest of the app. If every alarm, icon, and session list interprets raw events independently, the macOS and Windows products will disagree after the first format change.

Reduce provider events to a small contract

Agent Island reduces the newest relevant record to three fields:

  • isDone: whether the latest turn has handed control back to the user;
  • key: a stable identity for alarm deduplication;
  • activityDate: the semantic event time, when the record provides one.

The Swift type is SessionTurnStatus. The Windows port uses the same three fields in a C# record struct. Everything above that boundary can reason about a finished turn without knowing whether the evidence came from a Claude assistant message or a Codex event payload.

Read backwards, then stop at the newest meaningful event

The classifier scans the bounded transcript tail in reverse. That matters because an older completion marker is not the current state after a new user message starts another turn. The first recognized event wins.

for line in lines.reversed() {
    guard let event = json(line) else { continue }
    if event["isSidechain"] as? Bool == true { continue }
    // Return as soon as the newest relevant user or assistant event is found.
}

The sidechain exclusion prevents an interleaved subagent event from completing the parent conversation. This is a semantic rule, not a file-format cleanup. A valid JSON record can still belong to the wrong conversation.

A stop marker can still be an error

Claude Code can write a rate-limit or API-error line with the same assistant envelope and stop_sequence reason used by a completed turn. The record also carries isApiErrorMessage: true. Treating the stop reason alone as completion produces a false your-turn alert.

let isApiError = event["isApiErrorMessage"] as? Bool == true
let isDone = !isApiError
    && ["end_turn", "stop_sequence", "stop"].contains(stopReason)

The C# classifier applies the same exclusion. The unit fixtures on both platforms include this case. Cross-platform parity comes from porting the rule and its examples together.

Keep a stable turn key

Polling and filesystem events can classify the same completed turn more than once. A monitor therefore needs an identity that survives another scan. The released classifiers try uuid, id, then payload fields such as turn_id, item_id, and call_id. Only when no identifier exists do they derive a bounded fallback from the raw line.

This key belongs to the semantic result. Alarm code should not invent a second identity scheme from file paths or timestamps.

Port behaviour, not syntax

macOS uses Swift because the product is a native SwiftUI menu-bar app. Windows uses C# and WPF. Forcing both into one runtime would not remove the need for native lifecycle, filesystem, and UI integration.

The useful shared unit is the truth table. Given the same ordered evidence, both implementations must answer the same questions: Did a user event start work? Did a provider-specific completion marker finish it? Is the marker part of a sidechain? Is it an API error? Which event key identifies the turn?

That contract is small enough to review and test. Platform-specific code can then handle observation and presentation without changing what a turn means.

Test traces, not just helper functions

Good fixtures look like failures seen in real transcripts. Put an older completion before a newer user message. Insert a sidechain completion after the parent event. Use a Claude API-error envelope with stop_sequence. Cover both old and current Codex completion names.

Run the same cases against both ports. A new provider event should first change the semantic fixture set, then the Swift and C# adapters. This makes drift visible in review instead of after users see different alerts on different operating systems.

What the contract does not prove

A completed turn does not prove that the task succeeded, the code is correct, or the user should accept the result. It only says the latest recognized transcript event handed control back. Product UI should preserve that boundary.

Agent Island v1.7.1 uses this local evidence to show Claude Code and Codex status and your-turn alerts on macOS and Windows. It does not upload session data to Agent Island, and it does not execute the next step for the user.

Continue with bounded transcript monitoring, safe JSONL parsing, or completion event versus session state.

← All posts