Skip to content

Offload native macOS builds and host app windows on another Mac #2403

Closed
5 of 5 issues completed
Closed

Description

@janicduplessis

Problem

The maintainer wants to offload both native macOS builds and the launched app to another Mac, then view and control it from Stim Desktop or the phone app. Local builds and app execution currently consume resources on the main development Mac.

Evidence

User request on October 4, 2026: build and run macOS apps on the other machine so the main machine stays free. Native macOS build/launch support landed in #2397; phone window viewing in #2398 and Control in #2401. #1802 covers build offloading for iOS/Android, and #2266 covers hosted simulators. Open-issue search found no dedicated native macOS remote build-and-run task.

Cause

Native macOS development currently owns a local workspace app process/window. Existing offload and hosted-session contracts do not establish a remote native macOS app lifecycle and its owned window.

Fix idea

Extend the existing approved build-machine and hosting-machine flows to native macOS, with a product decision for selecting placement. Sync exact worktree inputs, build and launch in an isolated worker area, and expose remote status, build/app logs, window streaming and exclusive Control through the existing Desktop and phone viewers. The worker needs explicit Screen Recording/Accessibility onboarding; retain pinned node identity and separate build, hosting and viewer permissions.

Keep the main Mac free of the hosted app process and native compiler workload. Stop, cancellation, disconnect and ownership recovery must affect only the exact remotely owned app and its worker area. Never capture the worker desktop or unrelated windows, and never silently widen approval. Define how SwiftPM/Xcode native targets, signing, cache reuse, toolchain matching and local fallback behave before adding command selectors.

Acceptance: a real two-Mac worktree edit/build/launch/view/control/stop cycle from Desktop and phone, with readable failure/cancellation paths, exact app ownership evidence, and measured resources remaining on the main Mac. Track this after the local native macOS viewer and its permission onboarding; this issue does not claim current remote support.

Activity

  1. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Claimed: feat/2403-hosted-macos-host

  2. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Design: hosted macOS apps on an approved Mac

    Goal: a coding agent or a person on this Mac builds a native macOS app, runs it on another Mac (the mini), sees and drives it, and stops it, without SSH. The end state replaces mini-desktop.sh, which Stim Desktop test copies use today.

    Transport and approval: tailnet only, no new approval

    • Every request goes through stim-server's existing tailnet path, the same one build offload uses. The client finds the peer by its pinned Tailscale node ID (offload/tailnet.ts findPeer/endpoint), connects over wss to that node's address, and sends its token only to that node. No SSH, scp, rsync, Funnel or public tunnel at any step, including tests and acceptance.
    • Hosted macOS adds no third approval. It is a new session kind (platform: 'macos') under the existing device-host capability, so hosting.machines, stim doctor --fix (which asks the host) and stim-server devices grant <id> --device-host (run by a person on the host) already cover it. Revocation stops hosted macOS sessions through the existing DeviceHost.revoke path.
    • Build offload and device hosting already share the same machine flow: the same peer pinning, the same hello {request, deviceName} request from doctor --fix, and the same devices grant. device-host/machines.ts is close to a copy of offload/build-machines.ts. What is still duplicated is two settings (offload.machines and hosting.machines), two client credential files, two requests and two grants. Proposal, needs a product decision: one machine list, with one doctor --fix request that asks for both build and device-host, and one grant command that approves both (devices grant <id> --build --device-host). Each capability would stay separately grantable and revocable on the host. This conflicts with the October 5 note on Run owned simulators and emulators on paired Macs with load-aware placement #2266 ("build approval and device-host approval remain separate") and with the separate build-clients.json, so I will open it as its own issue and not change it in this stack.

    Protocol

    These are the existing device-host.* methods with platform: 'macos':

    • offer / reserve: the host checks that it runs macOS and reports its CPU architecture and macOS version. reserve journals a session in server/device-host-sessions under an exclusive ownership claim, as iOS and Android sessions do. A macOS session counts toward concurrency.maxDevices. When it is ready, its "device" is the host identity (architecture, OS version) plus the slot-assigned bundle id. No simulator is created and created-devices.json stays empty.
    • app.offer / app.chunk / app.launch / app.attach: the existing chunked, digest-verified delivery. The manifest accepts a macOS bundle (Contents/Info.plist, Contents/MacOS/<exec>). Install materializes the bundle with the existing escape, link and alias checks, then refuses an app whose executable has no slice for the host architecture, whose LSMinimumSystemVersion or vtool minos is newer than the host, or whose platform is not MACOS. No Metro.
    • stop and revocation: identity-verified SIGTERM, then SIGKILL, of the recorded supervisor and app (the macos/stop.ts path; devices/teardown.ts stays for simulators and emulators). Then the session's app data is removed (see Isolation). Teardown that cannot be verified leaves the session unknown, with its claim and records kept.
    • frames.subscribe / control.begin / input.*: reuse MacosSource, keyed by the hosted app's recorded identity (pid, start time, executable, bundle id). It checks that identity on every frame and captures one window of that exact process only, never the desktop. macOS input also needs device-host.input.scroll and device-host.input.key, which control.ts already requires for local macOS.

    Isolation on the host

    • The hosted app runs as an owned process under a supervisor in the session's worker area, device-host/sessions/<id>/, with its own STIM_HOME, HOME and CFFIXED_USER_HOME and its own TMPDIR.
    • The bundle id is assigned by the host from a fixed slot pool: <Info.plist CFBundleIdentifier>.hosted<N>. The bundle is re-stamped and re-signed on the host. A fixed pool, like the three test-copy slots in mini-desktop.sh, lets a person's Screen Recording and Accessibility approvals for the app under test survive rebuilds. An optional host-only machine setting names a stable keychain signing identity; without it the bundle is signed ad hoc.
    • Measured on this Mac: HOME and CFFIXED_USER_HOME move NSHomeDirectory() and Application Support, but not UserDefaults, because cfprefsd still writes ~/Library/Preferences/<bundle id>.plist. Preference isolation therefore comes from the bundle id. Stop deletes that bundle id's defaults domain and its per-bundle files, and nothing else. Keychain, app groups and TCC are per user and shared. This is the same limit Bring native macOS development to React Native workflow parity #2408 accepts for local stim macos, and it is documented.
    • The maintainer's own apps on the host are never signalled, captured or driven. Every signal checks the recorded process identity first.

    Permissions (TCC)

    A person grants Screen Recording and Accessibility once on the host, to the process that runs stim-frames. That is the existing signed capture host, with the existing onboarding text. Nothing is scripted or reset. If access is missing, the client gets the same refusal message that local stim macos viewing already shows.

    Client surface (proposed; flag added only in the client PR)

    • stim macos --host <machine>: builds the Debug product locally (or remotely, see the plan), delivers it, launches it, and records the placement in workspace state. A named host is strict: on refusal or when the host is unreachable, the command fails and never falls back to a local launch. --json keeps its single payload and adds host, session and agent.
    • stim status shows the host and session state. stim stop / worktree remove stop the hosted session. stim logs streams the hosted app's stdout and stderr.
    • People view it in Desktop and on the phone the same way as a local macOS app. The local stim-server relays frames.subscribe / control.* for a workspace placed on a host to that host over the pinned connection, using the client's device-host credential. Desktop still talks only to its loopback stim-server. The relay is shared with hosted iOS (Run owned simulators and emulators on paired Macs with load-aware placement #2266).

    Agent control with agent-device

    Findings (local only, isolated state directories, my own fixture app):

    • Local agent-device open <bundle> --platform macos, snapshot -i, click and screenshot work against a stim macos fixture. The screenshot is the app window only.
    • Through agent-device proxy plus connect proxy, devices --platform macos lists the host, but open refuses: remote leases exist only for ios-simulator, ios-instance, android-instance and harmonyos-instance. This holds in 0.21.12 and 0.21.20, and on upstream main, where LEASE_BACKEND_BY_PLATFORM maps the macOS desktop host to no backend on purpose. fix: infer API run results from the requested platform #2989 (the apple/ios platform-axis fix, in 0.21.16) does not change this.
    • Missing upstream: a macOS app lease backend whose device key is one exact app (bundle id, optionally pid). Proxy admission would then refuse any other bundle, the desktop / frontmost-app / menubar surfaces, install and launch of other apps, and anything other than window-scoped screenshots. A host administrator would allocate it through the existing loopback admin path, the way retainOnClose leases are kept today. That is the smallest change; I will propose it upstream with this design.

    Plan within the tailnet-only rule, as the maintainer decided:

    • The host's stim-server owns one agent-device daemon in HTTP mode, bound to loopback, with its own state directory under the server's Stim home. It is held by an ownership claim whose child is the daemon. It is reference-counted on live hosted macOS sessions: started with the first, stopped after the last, and also stopped on revocation and server shutdown. If it crashes while apps are running, it is restarted.
    • For each session, the host allocates a lease scoped to that app and issues a per-session agent token. It serves /device-host/agent/<session>/* on its existing tailnet serve route. Requests are accepted only from the approved client's tailnet node, only with that session's token, and only for that session's lease. They are forwarded to the loopback daemon with the daemon's own token, which never leaves the host.
    • The client writes an agent-device --remote-config file (daemonBaseUrl, daemonAuthToken, leaseId, platform: macos) with mode 0600 in its workspace directory. stim status --json and stim macos --json give only its path. The agent runs agent-device open <bundle> --remote-config <path>. The token is never printed.
    • One daemon instance serves every client and app on the host once the upstream lease scopes to one app. Until then, a client's agent cannot be limited to its own app, so this part waits for the upstream change.

    Parity with mini-desktop.sh

    mini-desktop.sh Stim
    build (rsync + bundle.sh over SSH, claim one of 3 slots, stable signing, defaults write) stim macos --host mini: remote build through offload (or local build + delivery), host slot pool for bundle ids, optional host signing identity, defaults passed as macos.arguments (-key value argument domain) instead of defaults write
    launch (open -n --env HOME=...) same command: an owned launch under the session's isolated home
    shot <out.png> agent-device screenshot --remote-config <path>, or Desktop/phone viewer
    click / rclick / key / type (cliclick) agent-device click/fill/type/press --remote-config <path>
    quit (frees slot) stim stop (verified stop, slot freed, defaults domain removed)
    clean stim worktree remove (session area removed on the host)
    3 concurrent slots concurrency.maxDevices on the host, one bundle-id slot per live session

    Out of scope here

    Placement policy (#2266), Xcode-project macOS targets, swift test on the host, Metro for macOS, store or distribution signing.

    Plan (stacked PRs, each reviewed and merged on its own)

    1. Host: macOS reservation, delivery and owned launch under an isolated home with a slot bundle id; journaled; verified teardown on stop, revocation and shutdown. No client surface.
    2. Host: hosted macOS frames and control through MacosSource, plus device-host.input.scroll and device-host.input.key.
    3. Client: stim macos --host <machine> (local build + delivery), status, logs and stop.
    4. Client stim-server relay for Desktop and phone viewing (shared with Run owned simulators and emulators on paired Macs with load-aware placement #2266).
    5. Remote macOS builds through build offload, with the artifact handed to the hosted session (building Stim Desktop on the mini is the main use).
    6. Host-owned agent-device daemon, per-session leases and the remote-config file. Blocked on the upstream macOS app lease.
    7. Guide and website: agents use stim macos --host and stop using mini-desktop.sh.
    8. Two-Mac acceptance (MacBook client, mini host): edit, build, launch, view, control, agent drive, stop, revoke. No SSH.
  3. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Addendum: agent control is automatic, behind a driver adapter

    When an app is launched on another Mac, agent control is set up with it, with no manual step. The driving tool can be replaced: agent-device is the first and default implementation, and another tool (for example argent) is added later as one more adapter, without protocol changes.

    • Setting: hosting.agentDriver, a machine-layer setting on the host, defined in the settings registry. Choices are agent-device (default) and none. A choice is added only when its adapter exists.
    • Adapter interface (host side). It covers what the lifecycle needs and nothing more:
      • start() / stop(): the driver's host daemon. stim-server reference-counts it on live hosted apps and holds it under an ownership claim whose child is the daemon. It stops when the last app stops, on revocation and on server shutdown, and restarts if it crashes while apps are running.
      • issue(session) / revoke(session): a credential scoped to one client and one hosted app, such as an agent-device lease allocated through its loopback admin path plus a per-session token.
      • forward(session, request): the HTTP forward behind /device-host/agent/<session>/* on stim-server's tailnet route. It checks the session credential and the client node, then forwards with the daemon's own token, which never leaves the host.
      • describe(session): returns { driver, env, files, command } for the client. For agent-device that is a 0600 --remote-config file holding daemonBaseUrl, daemonAuthToken, leaseId and platform, plus the command shape agent-device <cmd> --remote-config <path>.
    • Protocol: device-host.app.launch / app.attach results carry an opaque agent object built from describe. Only the client writes its files, and the client passes it through stim macos --json and stim status --json with secrets as file paths, never inline. Nothing else in the protocol names a driver.
    • Client side: Stim writes the described files into the workspace directory and removes them on stop. The guide tells agents to use the command that status reports. Changing drivers does not change the guide's workflow.
    • agent-device specifics stay in its adapter: proxy or daemon flags, lease backend, remote-config fields, and the upstream macOS app lease this needs (see above). Until that lease exists upstream, the agent-device adapter reports agent control as unavailable, with the reason, instead of handing out an unscoped desktop.

    This becomes plan step 6 ("host driver adapter + agent-device adapter"). It is still blocked only on the upstream macOS app lease.

  4. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Correction to the driver addendum: hosting.agentDriver defaults to none. A driver daemon starts only after the host's owner sets the setting, for example stim settings set hosting.agentDriver agent-device. Once it is set, the lifecycle above applies automatically: the daemon starts with the first hosted app and stops after the last one. When a hosted macOS app is running with no driver, stim status --json reports agent: { driver: 'none' }, and both stim doctor and stim guide name the setting, so an agent knows how to turn agent control on.

  5. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Interface contracts for parallel tracks

    These contracts let the #2403 tracks proceed in parallel. The shared types land in #2472 (packages/core/state/hosted-macos.ts, HostedAppDelivery.agent, setting hosting.agentDriver). Everything below is binding for the tracks; a change goes through a comment here first.

    1. Protocol (host stim-server, device-host capability, approved tailnet client only)

    Existing methods, now with platform: 'macos'. Track A widens HostedDevicePlatform, the parsers and the JSON Schema enums together with the worker that handles them:

    'device-host.offer':   { params: { platform: 'macos' }; result: HostedDeviceOffer }       // choice: HostedMacosChoice | null
    'device-host.reserve': { params: { workspace; slot; attempt; platform: 'macos' }; result: HostedDeviceSession } // + appSlot
    'device-host.attach' | 'device-host.stop'                                                 // unchanged
    'device-host.app.offer':  { params: HostedAppOffer }   // macOS: mode 'release' only, no devClientScheme, bundleId not com.apple.*;
                                                           // manifest has Contents/Info.plist + Contents/MacOS/<exec>
    'device-host.app.chunk' | 'device-host.app.launch' | 'device-host.app.attach'           // unchanged shapes
      // HostedAppDelivery.bundleId stays the offered id; the running app uses hostedMacosBundleId(bundleId, appSlot).
      // Results are HostedAppLaunch: agent?: HostedAgentGrant, sent once installed and never journaled:
      // { driver: 'none' } or { driver, path: '/device-host/agent/<session>/', token, scope } (scope = the driver's per-app handle, e.g. an agent-device lease id).
    'device-host.metro.*'   // refused for macOS
    'device-host.frames.subscribe' | 'device-host.frames.keyframe' | 'device-host.unsubscribe'
    'device-host.control.begin' | 'device-host.control.end'
    'device-host.input.touch' | 'device-host.input.text'                                       // existing
    'device-host.input.scroll': Methods['input.scroll']    // new, track B
    'device-host.input.key':    Methods['input.key']       // new, track B

    Agent HTTP route (track E), on the host's existing tailnet serve route: ANY /device-host/agent/<session>/<driver path>. It is accepted only with that session's grant token, only from the owning client's tailnet node, and only while the session's app is running. It is forwarded to the host's loopback driver daemon.

    2. Host session record and worker home

    The journal is server/device-host-sessions/sessions.json (version 1). It holds a HostedDeviceSession with platform: 'macos', a required appSlot (1-64, the lowest free slot among sessions that are not stopped, reserved in the journal transaction like Android's consolePort), and device: HostedMacosDevice | null whose appSlot matches. A macOS session counts toward concurrency.maxDevices.

    Worker area: device-host/sessions/<id>/:

    • home/ is the worker's STIM_HOME. It holds hosted-device.json (the HostedMacosDevice), no created-devices.json, and hosted-macos-app.json ({ bundleId }, the host-assigned id, used for cleanup at stop).
    • home/macos-app/ is a pseudo workspace root. Its MacosAppRecord lives at home/workspaces/<workspaceName(root)>/state.json under the key macos, written by the existing macos-run supervisor under its runtime claim.
    • home/app-home/ is the app's HOME and CFFIXED_USER_HOME, with tmp/ as its TMPDIR.
    • apps/<attempt>/ holds the verified blobs and the materialized App.app.

    Stop and revocation stop the supervisor and the app with identity-checked signals (stopMacosAppHeld). They then run defaults delete <hosted bundle id> and remove its plist. No other domain is touched.

    3. stim-frames helper for a hosted session

    This is unchanged from local macOS: stim-frames macos '<MacosAppState JSON>' with STIM_HOME set to the worker home. The fields it uses are bundle, bundleId (the hosted id), executable and app.{pid,startedAtMicros}. Track B adds readHostedMacosApp(home): MacosAppRecord | null to core/state. DeviceHost.viewTarget returns { platform: 'macos', app: MacosAppState } only while macosAppState(...).state === 'running'. A relaunch closes the session's view, as iOS install does today.

    4. Client CLI and JSON (track C)

    • stim macos --host <machine> [--json]: <machine> must be a hosting.machines entry with an approved credential. The Debug product is built locally (track D adds the remote build). The bundle is staged with its base CFBundleIdentifier, without the worktree hash, so the host's slot id stays stable across worktrees and the TCC approvals for the app under test persist. The command delivers and launches the app. A named host is strict: it never falls back to a local launch.
    • --json prints a single payload: { platform: 'macos', product, launchId, build, host: HostedMacosPlacement }. There is no local app or supervisor process. host.agent is a HostedAgentAccess: { driver: 'none', setting: 'hosting.agentDriver' }, or { driver: 'agent-device', remoteConfig: '<0600 file in the workspace dir>', command: 'agent-device <command> --remote-config <path>' }. A token is never printed.
    • Workspace state: macos.host: HostedMacosPlacement. stim status --json includes it under the environment's macos. stim stop and worktree remove send device-host.stop and delete the agent files.
    • Viewer route: the client's own stim-server relays frames.subscribe, control.* and input.* for { workspace, platform: 'macos' } to the host when the workspace state has macos.host, using the client's device-host credential over the pinned connection. Desktop and the phone talk only to their local server. This relay is the same one hosted iOS needs (Run owned simulators and emulators on paired Macs with load-aware placement #2266).

    5. Settings

    • hosting.agentDriver (machine, none | agent-device, default none): landed in feat: add hosted macOS app contracts #2472. Track E makes agent-device take effect, and makes doctor / guide name the setting when a hosted macOS app runs with no driver.
    • No other new settings in this stack. If track A needs a stable signing identity for the TCC persistence that mini-desktop.sh provides, it proposes hosting.macosSigningIdentity (machine-only, sensitive: false) here before adding it.

    6. Agent driver adapter (track E; host side, server package)

    interface HostedAgentDriver {
      readonly name: Exclude<HostedAgentDriverName, 'none'>;
      /** Starts the host daemon under an ownership claim (child = daemon); idempotent. Refcounted by the caller on running hosted macOS apps. */
      start(): Promise<void>;
      /** Stops the daemon with identity-checked signals; called when the last app stops, on revocation and on server close. */
      stop(): Promise<void>;
      /** Issues a grant scoped to exactly one hosted app (client, session, hosted bundle id, app pid). */
      issue(app: { client: string; session: string; bundleId: string; pid: number }): Promise<HostedAgentGrant>;
      /** Invalidates a session's grant and lease. */
      revoke(session: string): Promise<void>;
      /** Forwards one authenticated HTTP request for a session to the loopback daemon. */
      forward(session: string, request: IncomingMessage, response: ServerResponse): void;
    }

    The client-side adapter turns a HostedAgentGrant into a HostedAgentAccess (it writes files and builds the command). Everything specific to agent-device stays inside its two adapters: the binary path (resolved explicitly, ~/.local/bin/agent-device first, because the host's LaunchAgent PATH is only system directories), daemon flags, lease backend and remote-config fields. Agent control waits on an upstream macOS app lease in agent-device (see above). Until that exists, issue refuses, and the delivery carries { driver: 'none' } together with a notice.

  6. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Tracks, filed as sub-issues. Contracts: the comment above, plus #2472 (shared types and hosting.agentDriver, in review).

    Track Issue Can start Depends on
    A: host session, delivery, launch, teardown #2473 now (draft on feat/2403-hosted-macos-host, unreviewed) #2472
    B: frames and control #2474 on A's branch #2473
    C: stim macos --host, status/stop, viewer relay #2475 now, against a fake host #2473 for end to end
    D: remote macOS builds through offload #2476 now none
    E: agent driver adapter + agent-device #2477 upstream proposal and skeleton now #2473 and #2475 for end to end; the upstream macOS app lease for scoped control

    The branch feat/2403-hosted-macos-host now belongs to #2473. This issue stays open for the integration work and the two-Mac acceptance.

  7. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Track A (#2480) amends one number in the contracts: the hosted bundle id limit is 249 characters, not 255, because the host deletes ~/Library/Preferences/<id>.plist at stop and that file name must fit in 255 bytes. Also: agent: { driver: 'none' } is explicit on app.launch/app.attach results for a macOS app that is installed while its session is ready; absent otherwise (not installed, stopped, iOS/Android). Never journaled.

  8. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Track B (#2474) merged as #2485 (3db30ee). Host side: device-host.frames.subscribe, control.begin and input.* (including new device-host.input.scroll|key) accept a hosted macOS session while its app runs. Capture and control are keyed by the session's recorded app identity and refuse anything outside that session. TCC: Screen Recording and Accessibility go to the process that spawns stim-frames. On the mini the LaunchAgent dev.stim.server runs /opt/homebrew/bin/node, so that is the process to grant once (not scripted; the mini's actual grants were not checked). On the dev MacBook a real capture of the hosted fixture worked; control was refused at the helper's window-focus step (AXRaise returns -25205 while another app is frontmost, also for a plain non-hosted launch), so a click reaching the fixture is still unverified.

  9. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Two-Mac acceptance (2026-10-05)

    Setup: the MacBook was the client, with global stim at 3db30ee (Stim build digest fa234f983f733f37). The mini was the host and build machine. All product traffic went over the tailnet through the pinned node, with no SSH. SSH was used only to redeploy the mini's service and for read-only checks on it.

    Mini redeploy

    The mini's dev.stim.server now runs 3db30ee from a new versions/<sha> worktree. The steps were pnpm install --frozen-lockfile (8 s) and pnpm run build, then service install with the same port, --env and --path-prepend flags (no --serve).

    • The Stim build digest went from 65a89e99683a0cb2 to fa234f983f733f37, which matches the client, so offloaded builds are accepted.
    • Three listeners on 7787, and the log shows the tailnet endpoint. https://…:7443 answers 426 from the MacBook. The serve config is byte-identical to the previous deploy's.
    • The hashes of eight state files are unchanged, including both client lists and the sessions journal. The devices list is unchanged except last seen.
    • The older version directories are kept for rollback. Evidence is in deployment-evidence/<sha>/summary.txt on the mini.

    The maintainer then granted Screen Recording and Device Control and Data Access to /opt/homebrew/bin/node and restarted the service twice. Each restart stopped every hosted session; see below.

    Results

    Step Result Time
    stim macos --host janics-mac-mini --json with STIM_OFFLOAD_MODE=off, SwiftUI fixture (3 files) pass: one JSON payload, appSlot 1, agent: {driver: none} 17 s cold (build 15 s, then reserve, deliver and launch about 2 s); 13 s warm
    stim status --json pass: macos.host with the session, slot and hosted id; state: running instant (no network)
    Frames, device-host.frames.subscribe (probe) pass after the grant. Before the grant: "Screen Recording access is unavailable for stim-frames" first frame in 400 to 550 ms (1600x800 fixture, 1600x1373 Desktop)
    Control, AppKit fixture pass: click raised the count to 1; input.text plus input.key backspace typed "hello min"; input.scroll accepted. This worked with another app frontmost, and the fixture came to the front about 10 ms per input call
    Control, SwiftUI fixture and hosted Stim Desktop fail: "The captured owned window could not be focused for Control." This also fails with the app frontmost → #2496
    Desktop and phone viewing through the client relay (#2483, run from its branch) pass: macos-hosted advertised; relayed frames (first in 741 ms); relayed control h1 clicked and typed in the AppKit fixture; the Desktop macOS card showed the hosted window live (light and dark below). #2483 is merged as 90fdda5.
    stim stop --json pass: macos: {status: "stopped"} (not null here); the placement is cleared locally 1 to 3 s
    Host teardown (read-only checks) pass for all 6 sessions: journal stopped, no app process, defaults read <id>.hostedN reports the domain not found, no plist, app-home, App.app and blobs removed; receipt.json and logs/macos.ndjson kept by design
    STIM_OFFLOAD_MODE=force stim macos (offloaded build, local run), fixture pass: offloadedTo: janics-mac-mini 9 s (6.4 s swift build on the mini)
    STIM_OFFLOAD_MODE=force stim macos --host janics-mac-mini, fixture pass: built on the mini, fetched back, delivered to the mini 5 s warm
    Same, Stim Desktop from a fresh origin/main worktree (7a41544) pass: hosted as dev.stim.desktop.development.hostedN; the window showed the onboarding of its isolated home 152 s: build record 84 s (25 s swift build plus about 43 s of cold package downloads on the mini), then about 68 s to deliver 200 files / 94 MB → #2497
    Same, Stim Desktop from a second worktree (same repository area on the mini) pass, offloaded, local run 19.5 s cold for this worktree; 9.7 s warm
    Concurrent sessions pass: 4 hosted sessions at once (slots 1 to 4: two fixtures, a relay fixture and Stim Desktop)
    stim logs only the build output; the hosted app's macos.ndjson stays on the host → #2489

    The first offloaded Desktop build fell back to a local build (256 s) because the mini's service restarted mid-build (1006, "No such build job"). It is not a track D defect.

    Desktop hosted card through the relay, light and dark:

    Light Dark
    light dark

    Direct host control of the AppKit fixture (click and typing):

    Track B focus refusal

    It reproduces on the mini, so it is not caused by the MacBook's frontmost app. It is specific to SwiftUI windows. With identical grants and conditions, AppKit control works, and SwiftUI fails even when the SwiftUI app is frontmost. Stim Desktop is SwiftUI, so this blocks the main use. Filed as #2496 with the evidence.

    Parity with mini-desktop.sh

    mini-desktop.sh Stim today Status
    build (rsync, bundle.sh, stable signing, defaults write) stim macos --host <mac>; add STIM_OFFLOAD_MODE=force to build on the mini works. Gaps: the bundle crosses the tailnet twice (#2497); bundle.sh resources are missing (#2487); macos.arguments are not passed, so a hosted Desktop runs with default autopilot and server-port preferences instead of the test copy's defaults write (#2492); ad hoc signing (#2493 covers host TCC ownership)
    launch the same command: isolated HOME and STIM_HOME, slot bundle id works
    shot <out.png> Desktop macOS card or phone through the relay (#2483) works for people; no CLI screenshot for agents until the agent driver lands (#2477, blocked on the upstream macOS app lease)
    click, key, type device-host.input.touch, input.key, input.text, through the relay from Desktop or phone works for AppKit apps; fails for SwiftUI apps, including Stim Desktop (#2496); no agent CLI yet (#2477)
    rclick none: input.touch has no secondary button missing
    quit stim stop works (1 to 3 s, verified teardown, slot freed, defaults domain removed)
    clean stim worktree remove works on the client. On the host, the session directory keeps receipts and logs
    3 concurrent slots slot pool per session works (4 at once)

    Other gaps filed: #2498 (a host service restart stops every hosted session while the client's stim status still says running; stim macos --host recovers in a new slot) and #2499 (a missing macos.product is reported as an unexpected error).

    Cleanup: all placements are stopped and verified on the mini. Both worktrees and the fixture workspaces were removed through stim worktree remove. The scratch relay server is stopped and its scratch Stim home deleted. The mini's three older versions/ directories are kept.

  10. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Update after #2503 (9c73648), checked on the mini with a hosted SwiftUI fixture and hosted Stim Desktop (details in #2496):

    mini-desktop.sh Stim today Status
    click, key, type device-host.input.touch, input.key, input.text, input.scroll through the relay or a direct client works for AppKit and SwiftUI apps. SwiftUI fixture: click, typing, backspace and scroll landed. Hosted Stim Desktop: sidebar row clicks, a menu click and scroll landed. Still fails while the app shows a SwiftUI sheet, such as Desktop's first-launch Welcome sheet (#2507); add -setupGuide.completed YES to macos.arguments as a workaround. Still no agent CLI (#2477).

    macos.arguments are passed (autopilot and notifications off) and applied in the hosted Desktop. The rclick row is unchanged: no secondary button.

  11. janicduplessis commented on Oct 5, 2026

    @janicduplessis
    CollaboratorAuthor

    Hosted-app agent driving is verified end to end on the real tailnet path: a fixture app and a fresh worktree's apps/desktop were each built on the MacBook, hosted on the Mac mini under the signed Stim Host, and driven with agent-device (open, snapshot, click, fill, screenshot, close) through the macos-app lease. Refusals for another app, the desktop surface and full-screen capture held. Details and observations are in #2477.

  12. added
    type: featureAdds or changes behavior, including performance and polish
    priority: P2Normal; default when unsure
    area: clipackages/stim-cli, including its README and guide
    on Oct 10, 2026
  13. janicduplessis commented on Oct 10, 2026

    @janicduplessis
    CollaboratorAuthor

    Done: #2482 (4c6542d) offloads macOS SwiftPM Debug builds, #2485 (3db30ee) streams and controls hosted macOS apps, and #2503 fixes SwiftUI window control. The two-Mac acceptance and end-to-end agent driving through the macos-app lease are recorded above. Viewer sizing for hosted macOS windows is tracked separately in #3070 and #2945. Closing in a triage pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: clipackages/stim-cli, including its README and guidearea: serverpackages/serverpriority: P2Normal; default when unsuretype: featureAdds or changes behavior, including performance and polish

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions