Skip to content

About

Swift package for driving opencode, Claude Code and Oh My Pi over HTTP + SSE

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

257 Commits

Folders and files

Repository files navigation

CodingAgentKit

A cross-platform Swift package for driving coding-agent servers over HTTP + SSE. It speaks three agents behind one unified model:

  • opencode (opencode serve, 1.x or 2.x — the server is asked which) — multi-provider, file browsing, diffs, permissions.
  • Claude Code via claude-bridge — a subscription-billed Claude Code session as structured HTTP + SSE.
  • Oh My Pi via omp-bridge — the same wire protocol, so ClaudeCodeBackend(agentType: .omp) drives it.

Beside them, delegate (delegate serve) is a tiered task dispatcher: a packet goes in, a cheaper model tries it in an isolated worktree, a verifier decides, failures escalate, and the passing patch is applied; DelegateKit follows every run as a stream.

It compiles, tests, and runs on Linux and Apple platforms. No Keychain, no OSLog, no UIKit anywhere in the core — so it works headless on a server as well as inside an iOS app.

Why

opencode and the bridges all expose an HTTP surface with a Server-Sent Events stream. Their wire formats differ (opencode streams fine-grained part deltas; the Claude backend receives whole messages with deltas), but a client wants one transcript model to render. CodingAgentKit hides that difference behind a CodingAgentBackend protocol and a MessageReducer that folds either event style into one ordered [ChatMessage] — which is the reusable heart you can drop into a UIKit app, a CLI, or a TUI.

Modules

Product Purpose
AgentCore Transport (URLSession REST + SSE), unified models, CodingAgentBackend, MessageReducer, AgentConversation, protocols (SecretStore, SessionCache), swift-log facade. No backend specifics, no Apple-only imports.
OpenCodeKit Hand-written opencode client + event decoder + OpenCodeBackend (conforms FileBrowsingBackend).
ClaudeCodeKit Client for claude-bridge and omp-bridge (one protocol, AgentType picks the flavour) + event decoder + ClaudeCodeBackend, over one multiplexed SSE stream.
DelegateKit Client for the delegate daemon (tiered task dispatcher): packets, runs, tier health, approvals, held patches (read, apply, discard), and the run event stream.
CodingAgentKit Umbrella that re-exports AgentCore, OpenCodeKit, ClaudeCodeKit and DelegateKit.
AgentTestSupport MockBackend (scriptable, injectable mid-stream failure) + SSE replay helpers for previews and deterministic tests — no live server needed.
CodingAgentKitApple Apple-only companion: KeychainSecretStore, ConnectionProfile, ConnectionProfileStore. Empty on Linux so the core stays portable.
codeagent Scriptable CLI that exercises the whole stack.

OpenCodeKit, ClaudeCodeKit and DelegateKit depend only on AgentCore; the core never imports a concrete backend.

Requirements

  • Swift 6.1+ (developed and CI'd on 6.2).
  • Linux or Apple (iOS 18+ / macOS 15+).

Install

.package(url: "https://github.com/guitaripod/CodingAgentKit.git", from: "0.52.0")

Then depend on the umbrella, or just the pieces you need:

.product(name: "CodingAgentKit", package: "CodingAgentKit")
// or: "AgentCore", "OpenCodeKit", "ClaudeCodeKit", "DelegateKit"

Library usage

import CodingAgentKit

let backend = OpenCodeBackend(config: ServerConfig(
    baseURL: URL(string: "http://100.x.y.z:4096")!,
    credentials: BasicCredentials(password: "…")
))

let session = try await backend.createSession(title: nil, directory: nil)
let conversation = AgentConversation(backend: backend, sessionID: session.id)

Task {
    try await conversation.send("List the Swift files in this project.")
}

// One AsyncStream of full snapshots — ideal for a UIKit view controller.
// Auto-reconnects with backoff; surfaces status, permission prompts, failures, and connection phase.
for await state in await conversation.states() {
    render(state.messages)              // [ChatMessage], updated as events arrive
    spinner.isHidden = state.status != .running
    banner.isHidden  = state.connection == .live
    if let permission = state.pendingPermissions.first {
        try await conversation.respond(to: permission, decision: .once)
    }
}

Swap OpenCodeBackend for ClaudeCodeBackend (claude-bridge, or omp-bridge with agentType: .omp) and the rest is identical — that is the point of the unified model. For tests and previews, use MockBackend from AgentTestSupport instead of a live backend.

Resilience

AgentConversation is built to survive real-world sessions on flaky links:

  • Auto-reconnect with backoff — the event stream reconnects with capped exponential backoff + jitter, and every gap is healed with a catch-up messages(for:) refresh.
  • Status inference from streaming — opencode never sends an explicit "running" status, so streaming activity on an unfinished assistant message flips the state to .running on its own; clients always see a busy indicator.
  • Transcript-derived status recovery — status events that fired while disconnected are gone forever, so after every refresh the transcript is the source of truth: a completed or visibly-streaming last message corrects a stale status.
  • Divergence recovery — a text delta for a part the reducer has never seen means the local transcript diverged from the server's (a reconnect gap); instead of fabricating a bubble that starts mid-response, the delta is dropped and the transcript re-fetched.
  • Session cache — plug in a SessionCache (a pure-Foundation FileSessionCache ships in the core) and cold starts render instantly from disk while the live transcript loads; snapshots persist at turn boundaries.
  • Many observers, one connection — every states() caller gets its own stream over a shared refcounted connection (two panes on one chat cost one socket), and reconnect() re-dials underneath live observers.
  • One socket per server — against a protocol-2 bridge, BridgeStream multiplexes every conversation, the chat list and subagent activity over a single sequenced /stream connection with replay on reconnect, gap detection, and a heartbeat watchdog.
  • Approvals converge — a permission answered on another device resolves the pending card everywhere; answered ids are remembered so it never re-prompts.

Backend capabilities

Not every backend can do everything. Each backend declares a BackendCapabilities value; gate UI on it rather than on the concrete type. Calling an unsupported action method throws AgentError.unsupported; the optional read methods (usageQuota(), sessionUsage(_:), sessionSpend(_:), usageAnalytics(days:), subagents(for:), availableAgents()) instead return nil/empty when unsupported. searchTranscripts(_:limit:) throws AgentError.unsupported — gate it on supportsTranscriptSearch.

Beyond the flags, capability is also expressed as protocols a backend conforms to: FileBrowsingBackend (listing + content), GitObservingBackend (gitSnapshot/gitPatch/gitCommit — the project's repository, read but never operated), AuthenticatingBackend (ServerAuth — the split browser sign-in below), and SelfUpdatingBackend (ServerUpdate — a server that reports what it runs vs. what it could run, and updates itself through its own restart, restartRequired and all). Check conformance with as?.

Model and reasoning effort are chosen per prompt via SendPrompt.model / SendPrompt.reasoningEffort, not applied as a standing session setting.

Capability Flag opencode 1.x opencode 2.x claude-bridge / omp-bridge
File browsing (FileBrowsingBackend) supportsFileBrowsing ✅ ✅ ✅ ¹
Diffs supportsDiffs ✅ ✅ —
Permission prompts supportsPermissions ✅ ✅ —
Structured questions supportsQuestions ✅ ✅ ✅ ²
Multiple sessions supportsMultipleSessions ✅ ✅ ✅
Model selection (per prompt) supportsModelSelection ✅ ✅ ✅
Attachments (files/images in prompts) supportsAttachments ✅ ✅ ✅
Reasoning effort (per prompt) supportsReasoningEffort ✅ ✅ ✅
Clear conversation in place supportsClearing — — ✅
Fork session supportsForking — ✅ ✅
Rename session supportsRenaming — ✅ ✅
Revert to a message supportsRevert — ✅ —
Abort current turn supportsAbort ✅ ✅ ✅
Session usage (per-turn cost/tokens) supportsSessionUsage — ✅ ✅
Whole-session spend report — ⁴ — — ✅
Account-wide usage analytics supportsUsageAnalytics — ✅ ✅
Transcript search (whole machine) supportsTranscriptSearch — — ✅
Subagents supportsSubagents ✅ ✅ ✅
Server-side slash commands supportsCommands ✅ ✅ ✅
Goals (/goal) supportsGoals — — ✅ ⁵
Compaction as a transcript event supportsCompaction ✅ ✅ ✅
Interrupted turns reportsInterruptions — ✅ ✅
Stop background work supportsBackgroundStop — — ✅ ⁵
Live usage quota (rate-limit gauges) — ³ — — ✅ ⁵
Server-held queue supportsServerQueue — ✅ ✅
Session marks (saved/pinned/archived/read) supportsSessionMarks — — ✅
Schedules on the machine supportsSchedules — — ✅
ACP agents hosted by the server supportsMachineAgents — — ✅ ⁵
Conversation chapters — ⁷ — — ✅
Measured context window — ⁸ — — ✅ ⁵
Wait on a turn from another process — ⁶ — ✅ ✅

¹ The Claude bridge serves file listing and content (listFiles/fileContent); diff, find, and providers have no bridge equivalent yet and return empty. ² The bridge's questions arrive in the transcript rather than as a protocol prompt, and answersQuestionsByMessage is true: answer by sending an ordinary message, not by calling a respond endpoint. QuestionRequest.awaitingAnswer derives what is still open from the transcript. ³ No BackendCapabilities flag — probe by calling usageQuota() / additionalUsageQuotas(), which return nil/empty when the backend has no usage API. ⁴ Probe by calling sessionSpend(_:), which returns nil when the backend cannot price a conversation. ⁵ claude-bridge only: omp has no /goal, no background shells, no plan gauges, no measured-context route and hosts no ACP agents. ⁶ Ask turnWaitSupport(). turnWaitRequest(for:until:) vends a fully authorised request with no side effect that the server answers only when the session's turn ends or stops for the person — made to be handed to a background URLSession owned by the system, which may re-send it — and turnWaitResult(status:headers:body:sessionID:) reads the answer. Ask for until: .workSettles and a bridge that holds it waits for the whole stretch — nothing running, carried or queued, and ten seconds of quiet — answering TurnWaitResult.settled true, with duration and toolCount covering all of it; an older bridge answers at the turn's end and leaves settled out. The bridges hold GET /sessions/:id/wait open; opencode 2.x has its own POST /api/experimental/session/{id}/wait; opencode 1.x has no way to wait without sending a prompt, so it is never offered one. A transport failure reads .undetermined, never .serverTooOld. ⁷ No capability flag: ConversationState.chapters is adopted from every transcript read and every stream frame. claude-bridge 1.22+ and omp-bridge 0.13+ serve chapters; every other backend leaves the list empty. ⁸ Conformance to MeasuredContext: measuredContext(for:) asks claude-bridge's GET /sessions/:id/context what fills the window as the CLI itself counts it, and nil is "cannot say". No other backend conforms.

Beyond messages

A transcript is more than text and tool calls, and the Kit models the rest as first-class parts and reads rather than leaving them for each client to re-derive:

  • Compaction — a context compaction is a seam in the conversation: tokens before/after, how long it took, how many messages carried over, and the CLI's own summary. It arrives as a MessagePart, not as a wall of prose in the chat.
  • ConversationChapter — the stretches a conversation has been through, oldest first: where each began (the person's message, by turn and id), when, who named it (prompt, model, person, agent, schedule, fork, kept) and, once the next began, a one-sentence gist of what it came to. ConversationState.chapters takes them from every read and every chapters frame, and a revision counted lower than one already held is never adopted, so a slow read cannot undo a rename.
  • MeasuredContext — what fills a Claude conversation's window as the CLI itself counts it: system prompt, tools, memory files, skills, agents, the messages split by kind, the buffer held back and where auto-compaction starts. nil is "cannot say", never an estimate.
  • DevicePushRules — a phone's own notification choices on its push registration: whether finished work is news (settled, everyTurn, failures, never), the shortest work worth a push, whether asks are, and the muted chats.
  • ChatMessage.error — a turn a bridge ended with an error keeps the provider's own words, live and on a later read, so a failed turn shows as failed instead of a prompt nobody answered.
  • QuestionRequest — structured multi-question, multi-option asks with free-text "Other", plus the transcript-derived answer state above.
  • Subagents — subagents(for:) and subagentMessages(sessionID:agentID:) fetch a spawned agent's own transcript, keyed back to the tool call that spawned it.
  • AgentCommand — what a turn will actually resolve on that machine: built-ins, user, project and plugin commands, with argument hints.
  • SessionGoal — a /goal in flight, whether it was met or failed, and what it cost.
  • Tool-call summaries — ToolCallSummary turns raw tool JSON into the line a human wants to read ("Read ReconnectScheduler.swift · 148 lines"), so clients don't each write their own.
  • SessionSpendReport — the whole conversation priced turn by turn across the four token tiers, provenance stated, always an estimate; UsageAnalyticsReport widens it to the machine's month — daily buckets, models, projects, tools, what caching saved, records.
  • Git, read — GitSnapshot (branch, upstream drift, triageable status, recent commits, half-done merges), GitPatch, GitCommitDetail — via GitObservingBackend.
  • ServerAuth — a signed-out server as a state: beginSignIn() returns the status carrying the OAuth URL the machine printed, submitSignInCode(_:) hands over the code the browser produced and returns the signed-in status. The sign-in happens on the server; the person happens wherever they are.
  • ServerUpdate — what a server runs vs. what it could run, with the honesty baked into the type: the running binary's own stamp beside the checkout's, restartRequired, and a RemoteCheck that can say "the remote was never reached" instead of "up to date".
  • TurnInterruption — a turn the machine cut off, as data: the progress its transcript proves (tools, files, commands, partial answer), what queued behind it, whether it has been resumed.
  • Live compaction — ConversationState.activeCompaction carries a compaction as it runs (and keeps it running while a prompt queues behind it), so a client can show the seam being made, not just the seam.
  • listAllSessions(knownDirectories:) — the complete machine-wide listing, subagent sidecar sessions excluded (AgentSession.isSubagent), for the client that lists every chat on every server.

Discovering servers on a tailnet

You don't have to type IP addresses. AgentCore ships discovery primitives:

  • ConnectionProbe classifies any base URL: .ok(agentType:version:) (opencode 2.x from /api/info, 1.x from /global/health, claude-bridge or omp-bridge from /status), .authFailed, .unreachable, or .notAnAgentServer. Unreachable probes are retried once. This is what codeagent discover uses.
  • TailscaleClient fetches your tailnet's devices from the Tailscale API, with either OAuth client credentials or a raw API token (tskey-api-…).
  • TailnetScanner probes every device's addresses and hostname on the agent ports (default 4096/4098/4099, up to 16 concurrent probes) and returns ready-to-connect Suggestions — backend type, version, and whether a password is required — deduplicated to one per server, preferring hostname-addressed, no-auth entries.
let devices = try await TailscaleClient().fetchDevices(with: "tskey-api-…")
let suggestions = await TailnetScanner().scan(devices: devices)
for s in suggestions {
    print("\(s.name) → \(s.baseURL) (\(s.backend))\(s.requiresAuth ? " 🔒" : "")")
}

CLI

codeagent health   [--backend opencode|claude|omp] [--host URL] [--password …]
codeagent discover                   # probe URL, auto-detect backend
codeagent sessions
codeagent new
codeagent send <session-id> "<prompt>" [--model providerID/modelID] [--attach FILE]
codeagent stream <session-id>
codeagent diff <session-id>          # opencode
codeagent files [path]               # opencode
codeagent find <pattern>             # opencode
codeagent providers                  # opencode

Config resolves from flags, then environment: OPENCODE_HOST, OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME, BRIDGE_HOST, BRIDGE_PASSWORD, OMP_HOST, OMP_PASSWORD, with CODEAGENT_PASSWORD as the backend-agnostic fallback.

export OPENCODE_SERVER_PASSWORD=secret
codeagent send "$(codeagent new)" "Summarize README.md"

Running the backends

opencode

OPENCODE_SERVER_PASSWORD=secret opencode serve --port 4096 --hostname 0.0.0.0

Auth is HTTP Basic (username defaults to opencode). The Kit injects the Authorization header on both REST and the SSE stream.

Claude Code

A bridge service exposing Claude Code via the structured HTTP/SSE API used by ClaudeCodeBackend (typical port 4098). Configure with BRIDGE_HOST and BRIDGE_PASSWORD (Basic auth user "claude").

The service is reached over a private network (Tailscale recommended). Never expose publicly.

Oh My Pi

omp-bridge on port 4099 (Basic auth user omp, password required). Configure with OMP_HOST and OMP_PASSWORD.

Security model

Run every server bound to a private network. Tailscale is the firewall — point the Kit at the tailnet IP. opencode adds HTTP Basic on top; claude-bridge admits a node signed into the same Tailscale account and asks anyone else for its password; omp-bridge always asks.

Credential storage is abstracted behind SecretStore (an EnvironmentSecretStore ships in the core). An app supplies a Keychain implementation; the core never imports Security.

Cross-platform notes

  • SSE streams over native URLSession.bytes on Apple platforms and AsyncHTTPClient on Linux (where URLSession.bytes does not exist), both feeding the package's own incremental SSEParser. REST uses URLSession.data(for:), which exists everywhere.
  • No UIKit/AVFoundation/Combine/Security/os imports anywhere in Sources/. Logging goes through swift-log; an app bootstraps an OSLog backend, Linux uses stdout.
  • Every string the Kit puts in front of a person — error messages, tool-call summaries, status fallbacks — resolves through AgentText against AgentCore's own Localizable.xcstrings, so a localized app doesn't get English leaking out of its engine.

Develop & test

scripts/test.sh              # swift build + swift test (sets Linux LD_LIBRARY_PATH)
swift test --filter OpenCodeReducerIntegrationTests

Decoder and reducer tests run offline against fixtures captured from a live opencode server and the Claude bridge schema.

Documentation

swift package --disable-sandbox generate-documentation --target AgentCore

Used by

  • Tailscode — native clients for iOS (UIKit), Linux (GTK4) and macOS (AppKit), all built on this Kit.
  • claude-bridge and omp-bridge — the servers ClaudeCodeKit speaks to.

License

GPL-3.0 — see LICENSE.

About

Swift package for driving opencode, Claude Code and Oh My Pi over HTTP + SSE

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages