Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3ab5936
fix(bridge): serialize checkout runtime build/teardown so sessions st…
bharathm03 Aug 25, 2026
ebbb3e8
Run a worktree.setup block before an isolated session's agent starts …
bharathm03 Aug 25, 2026
57f9a5e
chore: pin Flutter 3.47.1 across CI, and settle the native_prebuilt q…
bharathm03 Aug 25, 2026
6c3213e
fix: close review findings on the worktree.setup path
bharathm03 Aug 25, 2026
08b6f8e
ci: offset build numbers past the old repo, and gate build-desktop on…
bharathm03 Aug 25, 2026
4ee100e
fix: answer state.snapshot with a freshly recomputed per-checkout status
bharathm03 Aug 25, 2026
3d587bc
Merge pull request #5 from antgrid-ai/fix/worktree-setup-review
bharathm03 Aug 25, 2026
80df256
Merge pull request #4 from antgrid-ai/fix/remote-terminal-status-cache
bharathm03 Aug 25, 2026
33b91c0
fix: a terminal's owner row dies with the manager's memory of it, not…
bharathm03 Aug 25, 2026
dcbfa0a
Make a new-session start a visible, cancellable operation (#10)
bharathm03 Aug 26, 2026
098e22b
ci(ios): SHA-pin dtolnay/rust-toolchain (#13)
bharathm03 Aug 26, 2026
b016402
A promotional grant must not block account deletion (#12)
bharathm03 Aug 26, 2026
cfa2018
Offline demo mode so the app is reviewable without a desktop (#11)
bharathm03 Aug 26, 2026
9b1ee15
The workspace rail recedes over the transcript instead of sitting on …
bharathm03 Aug 26, 2026
c549493
fix: make OSC 8 hyperlinks in the agent terminal actually clickable (…
bharathm03 Aug 26, 2026
fb99eb9
A remote session must not offer to open a folder on this machine (#16)
bharathm03 Aug 26, 2026
9ba4cfa
A folder on this disk must not become someone else's when this device…
bharathm03 Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup-android/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ runs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: '3.47.0'
flutter-version: '3.47.1'
cache: true # cache the Flutter SDK between runs

- uses: oven-sh/setup-bun@v2
Expand Down
47 changes: 36 additions & 11 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ env:
# and a cache is readable only from its own ref or the DEFAULT branch, so every
# release wrote 1.8 GB into a scope the next release could not read. The pub
# cache is the opposite trade (~158 MB, ~12s) and is worth its bytes.
FLUTTER_VERSION: '3.47.0'
FLUTTER_VERSION: '3.47.1'
LICENSE_API_URL: 'https://app.antgrid.ai'
# The only hand-maintained part of the version. Bump for a deliberate product
# major; the rest of the string is stamped from the clock and the run counter.
Expand All @@ -86,18 +86,25 @@ jobs:
# CalVer-ish: <major>.<days since Unix epoch>.<run number>, e.g. 1.20662.412.
# - days-since-epoch is UTC integer division, so it has no leading-zero or
# DST hazard and increases exactly once a day, forever.
# - run_number discriminates several releases within the same day.
# - the build number discriminates several releases within the same day.
# The commit and the build time are NOT encoded here (a hex sha is not a legal
# version component in semver, App Store, or MSIX) — they ride alongside as
# dart-defines.
version:
# Only the repo that owns the release destinations publishes. This workflow
# writes to `antgrid-releases` and the Microsoft Store, neither of which is
# scoped to a repo, so an ungated second repo in this org republishes over
# the first's releases with its own counter. Every downstream job needs
# `version`, so gating here disarms the whole workflow.
if: vars.RELEASE_REPO == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
outputs:
name: ${{ steps.compute.outputs.name }}
build_name: ${{ steps.compute.outputs.build_name }}
build_number: ${{ steps.compute.outputs.build_number }}
msix: ${{ steps.compute.outputs.msix }}
tag: ${{ steps.compute.outputs.tag }}
sha: ${{ steps.compute.outputs.sha }}
Expand All @@ -110,9 +117,26 @@ jobs:
IS_RELEASE: ${{ inputs.release }}
MODE: ${{ inputs.build_mode || 'release' }}
PLATFORM: ${{ inputs.platform || 'all' }}
BUILD_NUMBER_OFFSET: ${{ vars.BUILD_NUMBER_OFFSET }}
run: |
set -euo pipefail

# run_number is scoped to ONE workflow in ONE repository and restarts
# at 1 in a fresh repo, while Sparkle compares against the
# CFBundleVersion baked into the INSTALLED app -- so a repo move walks
# the counter back under what is already in the field. The guard in
# the macOS job turns that into a hard failure rather than a dead
# update affordance, but the offset is what stops it happening.
#
# Unset means 0, and unset is correct on the repo that owns the
# original counter -- that is what keeps both repos from consuming the
# same offset range while publishing still runs from the old one.
offset="${BUILD_NUMBER_OFFSET:-0}"
case "$offset" in
*[!0-9]*|'') echo "::error::BUILD_NUMBER_OFFSET must be a non-negative integer, got '$offset'"; exit 1 ;;
esac
build_number=$(( GITHUB_RUN_NUMBER + offset ))

if [ "$GITHUB_REF_TYPE" = tag ]; then
# A hand-pushed v* tag stays authoritative: the tag IS the version.
name="${GITHUB_REF_NAME#v}"
Expand All @@ -127,7 +151,7 @@ jobs:
echo "::error::release=true needs platform=all — publish requires the macOS and Linux artifacts"
exit 1
fi
name="${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${GITHUB_RUN_NUMBER}"
name="${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${build_number}"
fi

# MSIX takes four 16-bit integers with the revision pinned to 0, and it
Expand All @@ -150,6 +174,7 @@ jobs:
# full string for the tag, the release title, and APP_VERSION.
{
echo "name=$name"
echo "build_number=$build_number"
echo "build_name=$core"
echo "msix=$core.0"
echo "tag=v$name"
Expand Down Expand Up @@ -271,7 +296,7 @@ jobs:
run: |
flutter build macos --${{ env.BUILD_MODE }} \
--build-name=${{ needs.version.outputs.build_name }} \
--build-number=${{ github.run_number }} \
--build-number=${{ needs.version.outputs.build_number }} \
--dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
--dart-define=APP_VERSION=${{ needs.version.outputs.name }} \
--dart-define=GIT_SHA=${{ needs.version.outputs.sha }} \
Expand Down Expand Up @@ -449,18 +474,18 @@ jobs:
# release-notes link at a tag that does not exist.
TAG: ${{ needs.version.outputs.tag }}
VERSION: ${{ needs.version.outputs.name }}
BUILD: ${{ github.run_number }}
BUILD: ${{ needs.version.outputs.build_number }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
# Sparkle tools release used for sign_update only (the framework the
# app embeds comes from the auto_updater pod, independently).
SPARKLE_TOOLS_VERSION: 2.6.4
run: |
set -euo pipefail
# The app's own detection compares the version triple, but Sparkle's
# install compares CFBundleVersion (--build-number, i.e. run_number)
# — a release whose triple rises while the build number does not
# lights the in-app row and then has Sparkle report "up to date": a
# permanently dead affordance.
# install compares CFBundleVersion (--build-number, i.e. the
# offset run counter) — a release whose triple rises while the
# build number does not lights the in-app row and then has Sparkle
# report "up to date": a permanently dead affordance.
# Enforce a strictly increasing build against the currently
# published appcast. 404 = first release / earlier appcasts skipped
# as unverifiable (nothing to compare); any OTHER fetch failure or
Expand Down Expand Up @@ -624,7 +649,7 @@ jobs:
- name: Build Windows app
id: build
working-directory: app
run: flutter build windows --${{ env.BUILD_MODE }} --build-name=${{ needs.version.outputs.build_name }} --build-number=${{ github.run_number }} --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} --dart-define=APP_VERSION=${{ needs.version.outputs.name }} --dart-define=GIT_SHA=${{ needs.version.outputs.sha }} --dart-define=BUILD_TIME=${{ needs.version.outputs.built }}
run: flutter build windows --${{ env.BUILD_MODE }} --build-name=${{ needs.version.outputs.build_name }} --build-number=${{ needs.version.outputs.build_number }} --dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} --dart-define=APP_VERSION=${{ needs.version.outputs.name }} --dart-define=GIT_SHA=${{ needs.version.outputs.sha }} --dart-define=BUILD_TIME=${{ needs.version.outputs.built }}

- name: Copy bridge into output folder
if: ${{ steps.build.outcome == 'success' }}
Expand Down Expand Up @@ -830,7 +855,7 @@ jobs:
run: |
flutter build linux --${{ env.BUILD_MODE }} \
--build-name=${{ needs.version.outputs.build_name }} \
--build-number=${{ github.run_number }} \
--build-number=${{ needs.version.outputs.build_number }} \
--dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
--dart-define=APP_VERSION=${{ needs.version.outputs.name }} \
--dart-define=GIT_SHA=${{ needs.version.outputs.sha }} \
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-ghostty-ios-prebuilt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: '3.47.0' # keep in lockstep with deploy-ios
flutter-version: '3.47.1' # keep in lockstep with deploy-ios
cache: true

- name: Flutter pub get
Expand Down
33 changes: 27 additions & 6 deletions .github/workflows/deploy-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,26 +101,47 @@ jobs:
id: version
env:
VERSION_MAJOR: '1'
BUILD_NUMBER_OFFSET: ${{ vars.BUILD_NUMBER_OFFSET }}
run: |
set -euo pipefail

# run_number is scoped to ONE workflow in ONE repository and restarts
# at 1 in a fresh repo, but the stores remember every build number
# they have ever ACCEPTED for the product -- internal-track and
# TestFlight uploads included, so "not released publicly yet" does not
# reset it. Moving repos therefore walks the counter back under
# numbers already taken and every upload is rejected. The offset lifts
# the new repo's sequence clear of the old one's high-water mark.
#
# Unset means 0, and unset is correct on the repo that owns the
# original counter -- that is what keeps both repos from consuming the
# same offset range while publishing still runs from the old one.
offset="${BUILD_NUMBER_OFFSET:-0}"
case "$offset" in
*[!0-9]*|'') echo "::error::BUILD_NUMBER_OFFSET must be a non-negative integer, got '$offset'"; exit 1 ;;
esac
build_number=$(( GITHUB_RUN_NUMBER + offset ))

{
echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${GITHUB_RUN_NUMBER}"
echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${build_number}"
echo "build_number=${build_number}"
echo "sha=${GITHUB_SHA:0:6}"
echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} >> "$GITHUB_OUTPUT"

# Override pubspec's static +1 versionCode: Play rejects any upload whose
# versionCode was already used, and every CI build would otherwise stamp 1.
# github.run_number is monotonic per workflow, so each publish gets a fresh,
# increasing code. It must stay this workflow's OWN run_number — sourcing it
# from another workflow's counter would restart the sequence below codes
# Play has already seen and every upload would be rejected.
# The stamped build number is monotonic, so each publish gets a fresh,
# increasing code. It must stay THIS workflow's own run_number plus the
# repo-wide offset — sourcing the counter from another workflow, or
# dropping the offset, restarts the sequence below codes Play has already
# seen and every upload is rejected.
- name: Build signed release AAB
working-directory: app
run: |
flutter build appbundle --release \
--build-name=${{ steps.version.outputs.name }} \
--build-number=${{ github.run_number }} \
--build-number=${{ steps.version.outputs.build_number }} \
--dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
--dart-define=APP_VERSION=${{ steps.version.outputs.name }} \
--dart-define=GIT_SHA=${{ steps.version.outputs.sha }} \
Expand Down
43 changes: 35 additions & 8 deletions .github/workflows/deploy-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,21 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: '3.47.0' # keep in lockstep with setup-android/build-desktop
flutter-version: '3.47.1' # keep in lockstep with setup-android/build-desktop
cache: true

# Required whenever the iOS prebuilt download comes up empty: portable_pty's
# hook then compiles the crate, and the runner's preinstalled Rust lacks the
# device target. Keep it — a missing or stale iOS asset turns into a build
# failure rather than a source build without it.
- uses: dtolnay/rust-toolchain@stable
# SHA-pinned because `@stable` is a BRANCH — this action picks the channel
# from its ref, so the reference is mutable in a way even a tag is not. The
# pin freezes the action, not Rust; rustup still resolves stable at run
# time. `toolchain` is explicit so a re-pin onto a master SHA, where the
# input is required with no default, does not break the build.
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
toolchain: stable
targets: aarch64-apple-ios

- name: Flutter pub get
Expand Down Expand Up @@ -219,25 +225,46 @@ jobs:
id: version
env:
VERSION_MAJOR: '1'
BUILD_NUMBER_OFFSET: ${{ vars.BUILD_NUMBER_OFFSET }}
run: |
set -euo pipefail

# run_number is scoped to ONE workflow in ONE repository and restarts
# at 1 in a fresh repo, but the stores remember every build number
# they have ever ACCEPTED for the product -- internal-track and
# TestFlight uploads included, so "not released publicly yet" does not
# reset it. Moving repos therefore walks the counter back under
# numbers already taken and every upload is rejected. The offset lifts
# the new repo's sequence clear of the old one's high-water mark.
#
# Unset means 0, and unset is correct on the repo that owns the
# original counter -- that is what keeps both repos from consuming the
# same offset range while publishing still runs from the old one.
offset="${BUILD_NUMBER_OFFSET:-0}"
case "$offset" in
*[!0-9]*|'') echo "::error::BUILD_NUMBER_OFFSET must be a non-negative integer, got '$offset'"; exit 1 ;;
esac
build_number=$(( GITHUB_RUN_NUMBER + offset ))

{
echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${GITHUB_RUN_NUMBER}"
echo "name=${VERSION_MAJOR}.$(( $(date -u +%s) / 86400 )).${build_number}"
echo "build_number=${build_number}"
echo "sha=${GITHUB_SHA:0:6}"
echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} >> "$GITHUB_OUTPUT"

# github.run_number is monotonic per workflow, so each upload gets a fresh,
# The stamped build number is monotonic, so each upload gets a fresh,
# increasing CFBundleVersion — App Store Connect rejects a duplicate build
# number, exactly like Play rejects a reused versionCode. It must stay this
# workflow's OWN run_number: sourcing it from another workflow's counter
# would restart the sequence below build numbers ASC has already accepted.
# number, exactly like Play rejects a reused versionCode. It must stay THIS
# workflow's own run_number plus the repo-wide offset: sourcing the counter
# from another workflow, or dropping the offset, restarts the sequence
# below build numbers ASC has already accepted.
- name: Build signed release IPA
working-directory: app
run: |
flutter build ipa --release \
--build-name=${{ steps.version.outputs.name }} \
--build-number=${{ github.run_number }} \
--build-number=${{ steps.version.outputs.build_number }} \
--dart-define=SENTRY_DSN=${{ secrets.SENTRY_DSN }} \
--dart-define=APP_VERSION=${{ steps.version.outputs.name }} \
--dart-define=GIT_SHA=${{ steps.version.outputs.sha }} \
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ in the CLI run. Never take an agent's `analyze_files` result as the gate.
## Conventions (MUST / NEVER)

- **Zod everywhere** — all message types and config schemas use Zod v4 for runtime validation.
- **Adding a message type** requires ALL of: schema in `protocol.ts` → add to `AbMessageSchema` union → add to `KNOWN_TYPES` set → export the type → handle in the `index.ts` switch. Miss one and it silently fails. If the type reads or writes the working tree, it also belongs in `CHECKOUT_VARIABLE_MESSAGE_TYPES` — see below.
- **Checkout-scoped routing** — an isolated session runs in a managed git worktree, so anything filesystem-variable (files, tree, search, Git, commands, preview, terminals, the handler's judge cwd and destructive-path floor) must resolve from the session's checkout, never from the project path. `CHECKOUT_VARIABLE_MESSAGE_TYPES` (`bridge/src/protocol.ts`) is the authoritative set and is mirrored BY HAND as `kCheckoutVariableMessageTypes` (`app/lib/project/project_message_classification.dart`); the two drifting apart is silent. An app that doesn't advertise the `checkoutRouting` capability is refused a project holding a managed session rather than shown main's workspace beside an isolated agent. `WORKTREE_SESSIONS_SUPPORTED` (`bridge/src/worktree-capability.ts`) is the kill switch.
- **Adding a message type** requires ALL of: schema in `protocol.ts` → add to `AbMessageSchema` union → add to `KNOWN_TYPES` set → export the type → handle the `case` in `handleAbMessage` (`bridge/src/agent-core.ts` — the inbound switch; `index.ts` is only the commander CLI and routes no message types). Miss one and it silently fails. If the type reads or writes the working tree, it also belongs in `CHECKOUT_VARIABLE_MESSAGE_TYPES` — see below.
- **Checkout-scoped routing** — an isolated session runs in a managed git worktree, so anything filesystem-variable (files, tree, search, Git, commands, preview, terminals, the handler's judge cwd and destructive-path floor) must resolve from the session's checkout, never from the project path. `CHECKOUT_VARIABLE_MESSAGE_TYPES` (`bridge/src/protocol.ts`) is the authoritative set and is mirrored BY HAND as `kCheckoutVariableMessageTypes` (`app/lib/project/project_message_classification.dart`); the two drifting apart is silent. An app that doesn't advertise the `checkoutRouting` capability is refused a project holding a managed session rather than shown main's workspace beside an isolated agent. `WORKTREE_SESSIONS_SUPPORTED` (`bridge/src/worktree-capability.ts`) is the kill switch. Inbound `session:*` verbs are deliberately NOT in that set — they name a `sessionId` and the bridge resolves the checkout from the entry, so a `checkoutId` on such a frame is a second, conflicting answer to a question already settled host-side (`session:result` is in the set because it carries the checkout back OUT). `bridge/tests/checkout-protocol-contract.test.ts` pins it, `session:setup` included.
- **Command execution is gated by account membership AND one machine-level remote-access switch, not by origin.** A phone is trusted the moment the bridge resolves its identity from the signed-in user's account inventory (no pairing ceremony); trust alone is NOT enough. A remote phone may drive project X iff it is account-trusted **AND** the machine's remote-access boolean is on (`remote-access-policy.ts`, the sole authorization store — `paired-phones.ts` is identity/push/last-seen only) **AND** X is in the host's project catalog (`seenProjects` in `host-server.ts`). Default is off on a fresh install; off is machine-wide and immediate. That catalog lookup plus `isSafeProjectId` are the *only* thing bounding which projectId a phone may name — nothing backs them up, so never refactor them away as redundant. Loopback/local callers are exempt by design (`currentPhoneAllowed()` in `agent-core.ts`): the desktop drives its own machine with the switch off. The `antgrid phones remove` CLI is **not** a revocation — see `docs/commands.md`.
- **NEVER make encryption optional.** All agent↔app messages are encrypted after handshake; the relay never holds decryption keys.
- **The repo is dual-licensed and the boundary is one-way.** `packages/antgrid-wire` and `packages/antgrid_relay_client` are Apache-2.0 and carry their own `LICENSE`; everything else Antgrid owns is ELv2 (`LICENSING.md` is the map). Apache code may be used inside an ELv2 component — **never move a file the other way**. Hoisting a shared helper out of `bridge/`, `relay/`, `web/` or `app/` into either package relicenses it permissively, and once published that cannot be undone. It compiles, CI stays green, and nothing warns you.
Expand Down
Loading