Development guide
Development guide#
Build, test, and contribute to Twine, or package a release. For installation and help using the app, read the user guide.
Twine consists of the macOS front end in macOS/ and the reusable application
library in twine-core/. The twine-bridge/ adapter exposes the library through
a C interface for the front end.
Development setup#
- Install Xcode 27.0 and point the developer tools at it:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer. The SwiftTerm build plugin and SwiftLint both require Xcode rather than the Command Line Tools. - Install Xcode's Metal Toolchain if it is missing:
xcodebuild -downloadComponent MetalToolchain. - To build the library, install the latest stable Rust with
rustup, which reads the version fromrust-toolchain.toml. - Build once from the repository root:
make debug. - Open
macOS/Twine/Twine.xcodeproj, select theTwinescheme, and build. Trust the SwiftTerm build plugin when Xcode prompts. Xcode consumes the prebuiltTwineCore.xcframeworkand does not invoke Cargo.
From the repository root, the root Makefile runs the same builds and checks from the command line. make lists the main commands:
make debug # native Debug app with incremental Swift compilation
make release # optimized candidate without debug symbols
make check # library and app lint plus unit tests
make check-lib # library only: rustfmt check, Clippy, workspace tests
make check-app # app only: swift-format lint, SwiftLint, unit tests
make fmt # format the library and the app
make ui-test # default UI suite; takes over the desktop
make ui-test ALL=1 # all UI tests, including retired tests and the launch benchmark
make ui-test-visual # optional appearance and screenshot checks
make ui-test ONLY='testFolderWindowInLightAppearance testFolderWindowInDarkAppearance'
make ui-test ONLY=testLaunchPerformance
make clean # remove all generated output and isolated dev/test state
The generated framework includes its C header and module map and is ignored by Git. If the framework is missing, package resolution reports the command needed to create it.
Both builds target Apple Silicon. make debug uses Cargo’s debug profile and incremental Swift compilation. make release builds with Rust LTO and Swift whole-module optimization, without debug information or dSYM. The apps are at out/debug/Build/Products/Debug/Twine.app and out/release/Build/Products/Release/Twine.app.
Cargo owns target/. Frameworks, Xcode products and DerivedData, Swift dependencies, tests, packaging, and generated documentation live in out/. Debug builds have a separate bundle identity and keep their database, transcripts, layouts, attachments, and default configuration in out/runtime/debug/. Unit and UI test builds and results live in out/tests/; fixtures use the system temporary directory and are excluded from make clean. Explicit data/configuration directory overrides are honored and are not followed by cleanup.
make clean runs cargo clean and removes the out/ directory plus known development/test preferences, caches, and saved window state in ~/Library. It also removes local Release candidates and packaging outputs. Installed apps in /Applications, production saved data and configuration, and separate POC projects are preserved. Cleanup does not build or resolve dependencies. make clean-dev-state removes only the development/test data and keeps build outputs.
Debug and Release frameworks are stored separately. A generated local Swift package selects the current profile; run profile builds sequentially. Unchanged framework contents retain their timestamps so incremental Swift builds remain useful. Use Makefile builds and tests to keep Xcode outputs in the checkout.
Linting prepares the framework before resolving the app's packages. The default UI suite runs the tests listed in UI_DEFAULT_TESTS in the Makefile. Other tests are retired from default runs; their source stays in place and they still build. Add a test to UI_DEFAULT_TESTS to re-enable it by default.
ONLY accepts one UI test name or a quoted, space-separated list, including retired tests, and overrides the default selection. ALL=1 runs all UI tests, including the appearance checks and launch benchmark. make ui-test-visual runs the optional visual checks.
Each target builds what it needs first. PREBUILT=1 runs a target without its prerequisites, for example make ui-test PREBUILT=1 after make build-tests. SHARD=n/N runs every Nth default UI test, starting at the nth. RETRIES=n reruns each failed UI test up to n more times. An explicit ONLY selection takes precedence over ALL=1, and both take precedence over SHARD. All UI test runs take over the desktop.
Tests run from the built test products, not from the Xcode project. CI builds them once per run, and each test job unpacks them with make unpack-tests, so a failed test job reruns without a rebuild.
Pull requests run .github/workflows/pr.yml: each job runs one Makefile target, selected by the changed paths, and Required PR checks is the required check. App changes also run the UI tests in four shards; a failed UI test is retried once, and maintainers re-run jobs that still fail from a flake. Each test job's summary lists its failed tests with the command to run each locally. After a merge, ci.yml builds the release candidate and site.yml publishes this website.
SwiftLint runs the version pinned in Package.resolved, through macOS/Twine/Scripts/swiftlint.sh.
Harness integration#
Agents advance stages by invoking the private completion command included in their instructions.
The command accepts JSON on stdin: decision is done, approve, or requestChanges, with
a summary and, for assignment handoffs, assignments containing role, instance, task,
and files. Until the task is known, the first completion must also include task. Each invocation gets a new mailbox; submissions are atomic and limited to 64 KiB.
Rejected submissions can be corrected and resubmitted. Terminal prose and process exits never
advance a stage.
Startup recovers interrupted work across all folders before publishing state. Agents retain their individual lifecycle status, and unfinished trace spans gain a stopped event without changing earlier events or transcript anchors. Completed and cancelled runs keep their outcomes. Recovery requires exclusive ownership of the data directory; another live Twine core prevents startup.
Harness launch and tracing compatibility#
Twine reads each harness's models from its own CLI (codex debug models, pi --list-models, agy models, omp models --json, opencode api model.list, and
the aliases in claude --help); Claude Code also takes any typed model name. A YOLO checkbox skips
the harness's permission prompts (--dangerously-bypass-approvals-and-sandbox for Codex,
--dangerously-skip-permissions for Claude Code and Antigravity, --auto-approve for OMP); pi doesn't ask for permission, so it has none.
Claude Code, Codex, pi, and OMP also report prompts, tool calls, and responses through launch-only observers.
Single-agent runs show a span for each prompt; multi-agent steps stay under the role's assignment.
Select a step to jump to its recorded terminal position. Hook payloads are bounded and recording
is best effort. If hooks are unavailable or disabled, the ordinary agent span remains. User and
project harness config files are never edited. Codex hook compatibility is verified with CLI
0.159; Twine trusts only its own invocation hooks and preserves existing hook policy.
Pi uses a temporary extension supplied with --extension, verified with pi 0.99.1. It observes
prompts as they enter the agent (including queued prompts), tool execution, and final response
settlement. Delivery never waits on Twine, and user/project extensions remain enabled.
Antigravity uses --prompt-interactive for initial tasks and keeps its terminal open for follow-up
prompts. It retains lifecycle and workflow spans; per-prompt and tool observers aren't installed
because agy doesn't expose a launch-only hook option. Its models and effort levels are discovered
from agy models and agy --help, verified with agy 1.2.5 and 1.2.14.
OMP uses an explicit launch subcommand and literal positional prompts. Its JSON catalog supplies
provider groups and model-specific thinking levels; typed model selectors and aliases also work.
Thinking options come from omp --help and use --thinking. OMP shares the temporary observer
extension with pi, using OMP's continuation-aware agent_end event and observing only the root
agent, verified with OMP 18.4.5. User and folder extensions remain enabled.
OpenCode v2 uses mini --standalone, keeping its interactive terminal and private server within
the agent's lifetime. Initial tasks use --prompt; model-specific effort choices select the model's
named variant with --model provider/model#variant. Its CLI API supplies model names, provider
groups, and variants for the open folder; catalogs are cached per folder. Custom selectors can
include other variant names. The mini interface
has no auto-approve or temporary-plugin flag, so YOLO is unavailable and it retains lifecycle
and workflow spans. Compatibility is verified with OpenCode 2.0.21.
Codex launches with --no-alt-screen and pi with --tui-mode regular so output remains in
scrollback. Claude Code, OMP, and OpenCode's mini interface already render inline; harnesses
without an inline launch option retain their own display setting.
Harness integration tests#
The automated Rust suite uses fake harnesses for complete runs. To repeat the optional real Adversarial smoke test with an authenticated Codex installation:
TWINE_REAL_CODEX="$(command -v codex)" make test-lib \
LIB_TEST_ARGS='real_adversarial_workflow_completes -- --ignored'
This test uses Codex's noninteractive mode in a disposable folder and verifies both agents' explicit completion signals.
To verify the installed Codex's actual prompt/tool/response hooks and terminal anchors:
TWINE_REAL_CODEX="$(command -v codex)" make test-lib \
LIB_TEST_ARGS='real_codex_records_tool_steps -- --ignored'
This opt-in test uses interactive, authenticated Codex in a disposable folder with a read-only sandbox.
To verify pi's interactive trace events using both DeepSeek Flash and DeepSeek V4 Pro:
TWINE_REAL_PI="$(command -v pi)" make test-lib \
LIB_TEST_ARGS='real_pi_deepseek_models_record_tool_steps -- --ignored'
This opt-in test uses your existing DeepSeek authentication, disposable folders, and the bash tool to print a marker. It does not edit pi settings or save sessions.
The extension's queued-prompt, tool-correlation, Unicode, and unavailable-endpoint checks need only Node:
TWINE_NODE="$(command -v node)" make test-lib \
LIB_TEST_ARGS='pi_extension_preserves_queued_prompts_and_tool_identity -- --ignored'
Extending configuration#
Add settings as fields with defaults in twine-core/src/config.rs. The Serde schema drives
parsing, unknown-key warnings, the generated default file, and snapshot serialization. Global
imports merge raw TOML tables before defaults are applied. Resolved terminal palettes are output-only
and omitted from the starter config. Extend Swift's CoreConfig when a consumer needs the new field.
Transcript implementation#
Retention is capped at 64 MiB per terminal and 512 MiB across all terminals. Output is stored in segments of at most 1 MiB, with at most 512 segments and 1,024 terminal metadata entries. Old segments or terminal entries expire when a limit is reached, so many short transcripts may expire before the byte limit. Metadata and its atomic replacement are each capped at 1 MiB, giving a maximum of 514 MiB of transcript file contents, plus bounded filesystem overhead. Pruned ranges remain expired after reopening.
Application::read_terminal_transcript(terminal_id, offset, limit) returns a page of exact bytes
starting at an absolute byte offset. Reads are limited to 64 KiB. An offset at the recorded end
returns an empty page; an offset beyond that end is an error. Pruned history returns
TranscriptRead::Expired with the earliest retained offset, or None when no output remains.
Unknown IDs and unavailable storage return typed errors. Byte offsets do not describe terminal
screen state; ANSI replay and resize handling belong to the history viewer.
Replay requires the complete output prefix and its resize history. Resize metadata is bounded to 8,192 entries across transcripts and 256 pending entries per terminal; losing geometry expires replay while preserving any retained raw output.
Recording uses a worker with at most 4 MiB / 256 requests pending, plus one in-flight request. The worker combines adjacent queued output from one terminal into batches up to 64 KiB, preserving read order while avoiding a separate durable commit for every small PTY read. Backpressure pauses output readers without holding terminal input or application-state locks. Storage failures leave live input and output usable and make transcript reads fail explicitly. Reads wait for previously accepted output to commit and must run off the UI thread. Clean shutdown flushes accepted recording; a crash may lose pending bytes. Reopening discards uncommitted tails and orphan files, and reports damage to committed data instead of silently skipping it.
Application::request_terminal_transcript provides nonblocking admission and polling for the
history viewer. A full queue returns no request; polling a pending request returns no result,
so terminal input and live output polling can continue while disk reads are pending.
One core owns a transcript directory at a time; a second concurrent owner receives an explicit error. The ownership lock is released after the recording worker has flushed and stopped.
Releases#
Pushing a vMAJOR.MINOR.PATCH tag runs .github/workflows/release.yml and creates a
draft GitHub Release. It reuses the unsigned build from successful main CI
for that commit. CI keeps that build for 90 days; after that, re-run CI on main for the commit first.
To cut a release:
- Set the version in
Cargo.tomland updateCargo.lock. Set the Twine app's Marketing Version in both Debug and Release configurations to the same version. - Move the finished changelog entries into a dated section such as
## [0.1.0] - 2026-09-30, leaving[Unreleased]above it. The release body comes from exactly that section, following the same extraction as vhrn. Missing, empty, or duplicate sections fail the release. - Review and merge through a pull request. Wait for push-to-main CI on that commit to pass.
- Create and push the tag on that reviewed main commit:
git tag -a v0.1.0 <reviewed-main-commit> -m 'Twine 0.1.0'
git push origin v0.1.0
- Wait for Release to finish. Download the draft DMG, check its checksum, and test dragging
Twine into Applications and first launch on a Mac with quarantine enabled using the install notes. Check a shell
and the harnesses you intend to support, then publish the draft in GitHub Releases.
Publishing a release that is not a prerelease notifies the Homebrew tap
(
standing-desks/homebrew-tap) to update its cask.
The release contains:
Twine-VERSION-macos-arm64.dmg: Developer ID signed, notarized app and Applications folder shortcut in a compact installer window. The license is bundled atTwine.app/Contents/Resources/LICENSE. Open the disk image, drag Twine to Applications, eject the image, and launch the installed app.libtwinecore-VERSION-macos-arm64.tar.gz: the static C ABI library aslibtwinecore.a, matching C header, usage README, and license. The protocol is experimental; pin matching header/library versions.twine-VERSION-source.tar.gz: the tagged Git source tree.SHA256SUMS: SHA-256 checksums for the disk image and two archives.
The released app's short version matches the tag; its build number comes from main CI. The release workflow signs the app with Developer ID and Hardened Runtime, notarizes and staples it, then signs, notarizes, and staples the DMG before generating checksums. Failed signing or notarization prevents publication. Releases are for macOS on Apple Silicon only.
Re-run failed jobs to retry a draft release. Published releases are never overwritten; use a new patch version for a correction after publication. Do not move an existing release tag.
make release builds out/release/Build/Products/Release/Twine.app
for local testing. CI calls .github/release/bundle-artifact.sh directly to collect
the build files and create the signed release artifacts. The source archive always
uses committed HEAD.
make check-bundle tests changelog extraction, signing,
notarization, and credential cleanup, and lints the release bundler scripts.
The signing tests use mocked Apple tools.
make build-dmg APP=path DMG=path creates a DMG from an already signed
release app. make validate-dmg DMG=path mounts an existing DMG read-only
and validates its format, contents, Finder layout, artwork, app versions,
architectures, signature, and entitlements. It requires APPLE_TEAM_ID
and checks Developer ID signing, stapled notarization tickets, and Gatekeeper
acceptance. Use TWINE_SIGNING_MODE=adhoc for local images without
Developer ID signing or notarization. Add VERSION=MAJOR.MINOR.PATCH
to require a specific app version.
Main CI builds and validates a DMG from the actual release app.
The release workflow validates the signed DMG before uploading it.
make lint-ci lints the workflows and build scripts.