Kiro Crew Desktop App
September 13, 2026 · View on GitHub
The desktop app is an Electron shell that wraps
the Kiro Crew web dashboard and embeds a self-contained Python backend. The
backend uses a python-build-standalone
(PBS) interpreter with all dependencies installed via uv/pip into the bundled
interpreter — end users need no Python, pip, npm, or node. They just
double-click the app and the dashboard opens.
The Electron sources live in website/electron/; the
build is driven by packaging/build-desktop.sh.
What make desktop produces
make desktop # macOS: ONE universal DMG (arm64 + x86_64) · Linux: AppImage + deb + rpm
UNIVERSAL=0 make desktop # macOS: faster host-arch-only DMG (local iteration)
Output lands in website/electron/dist/:
| Command | Platform | Artifact |
|---|---|---|
make desktop | macOS | KiroCrew-<version>-universal.dmg |
UNIVERSAL=0 make desktop | macOS | KiroCrew-<version>-arm64.dmg (Apple Silicon host) or KiroCrew-<version>.dmg (Intel host) |
make desktop | Linux | KiroCrew-*.AppImage, *.deb, *.rpm (host arch) |
The electron-builder configuration lives in
website/electron/package.json:
- appId:
dev.kirocrew.desktop - productName:
KiroCrew - macOS display name:
Kiro CrewviaCFBundleDisplayName;CFBundleNameremains aligned withproductNamebecause Electron uses it to locate theKiroCrew Helperapp bundles during startup - mac target:
dmg(categorypublic.app-category.developer-tools). The DMG uses a 660×420 logical-size branded drag-to-Applications background, packaged as a multi-resolution TIFF with 660×420 (1×) and 1320×840 (2×) representations for Retina displays. The background is a flat light purple carrying the opening animation's white ghost cast and wordmark, with a single chevron between the 96px app and/Applicationstargets. It holds no gradient: the brand guideline restricts them, so the accent is one tone. Nothing is painted behind the icon captions either — Finder draws them in dark text even under Dark Mode, so they read on the accent directly. - Windows target: assisted NSIS. A 164×314 welcome/finish sidebar and a 150×57
page header reuse the Kiro Crew logo while preserving native NSIS controls,
localization, the per-user default, and the no-UAC default path. The installer
cross-fades the native top-level dialog at page boundaries with Win32's
alpha-blended window animation, honoring the client-area animation preference.
It performs no timer-driven bitmap work or
Sleepon the NSIS UI thread; Windows CI installs the real artifact, records its duration, and enforces a 5-minute ceiling. Auto-updates skip the assisted wizard's decision pages but keep its native extraction progress visible, then relaunch Kiro Crew and close automatically. A legacy silent/S --updatedinvocation is converted to the same visible update path so the transition works from already-fielded clients. - linux targets:
AppImage,deb,rpm(categoryDevelopment). One backend tree is packaged three times, withscripts/stamp-distribution.shre-run between electron-builder invocations so each artifact's beacondistnames its OWN format -- a single stamp would label one artifact as another. desktopName+linux.syncDesktopNameare what make window association work: Electron derives its app_id fromdesktopName, and electron-builder derives the.desktopfile's name andStartupWMClassfrom the same value, so the three agree by construction instead of by coincidence. OverridingStartupWMClassby hand breaks that agreement.deb.dependsdeclares alternatives (libgtk-3-0 | libgtk-3-0t64) because Ubuntu 24.04's 64-bittime_ttransition renamed several libraries;rpm.dependsneeds no such thing but uses entirely different names (gtk3,nss,alsa-lib). Both lists are verified against a realapt-get install/dnfresolution byscripts/smoke-linux-packages.sh.
macOS default — one universal DMG for both arches
On macOS, make desktop produces a single KiroCrew-<version>-universal.dmg
running natively on both Apple Silicon and Intel Macs. It needs only
one Apple-Silicon machine — no Intel host, no second build. (It requires
an Apple-Silicon host with Rosetta 2; the script fails fast with instructions
otherwise, and UNIVERSAL=0 is the opt-out.)
macOS opt-out and Linux — host-arch-only builds
UNIVERSAL=0 make desktop (and every Linux build) produces an installer for
the host OS and host CPU architecture only. The python-build-standalone
interpreter is architecture-specific (honors the host arch) and, in this mode,
the bundled backend's architecture is coupled to the installer's — you
cannot mix (e.g. an arm64 DMG carrying an x86_64 backend). Use it for faster
local iteration on macOS (~half the build time and disk of universal), or on
an Intel Mac where the universal build cannot run. Per-arch targets:
| Target | Build host | Produces |
|---|---|---|
| macOS arm64 (Apple Silicon) | Apple Silicon Mac (UNIVERSAL=0) | arm64 .dmg |
| macOS x86_64 (Intel) | Intel Mac | x86_64 .dmg |
| Linux x86_64 | x86_64 Linux | x86_64 .AppImage, .deb, .rpm |
| Linux aarch64 (Graviton/ARM) | aarch64 Linux | aarch64 .AppImage, .deb, .rpm |
Both Linux architectures ship. build-desktop.yml builds them on
ubuntu-22.04 and ubuntu-22.04-arm, and publish-linux.yml runs once per
arch — each writing its own immutable S3 key, its own electron-updater channel
file (latest-linux.yml for x64, latest-linux-arm64.yml for arm64) and its own
latest alias. Published basenames are KiroCrew-<arch>.<ext> for each of the
six (arch, format) pairs -- KiroCrew-x86_64.deb, KiroCrew-aarch64.rpm, and so
on. A package format also gets its own feed DIRECTORY
(feed/<channel>/deb/latest-linux.yml), because electron-updater derives the
channel FILE name from platform and arch with no hook to change it, so two
formats sharing a directory would overwrite each other's metadata.
Two properties are load-bearing and worth knowing before you touch that lane:
- Linux is built natively per arch, never cross-compiled.
build-desktop.shprovisions a python-build-standalone interpreter and then runs it (pip install, plus thepython -m kiro_crew --versionself-containment gate and its companionimport kiro_crew.clichain probe — bare--versionanswers before the heavy imports, so the probe carries the gate's meaning), so a host that cannot execute the target architecture cannot build it. macOS gets away with one host only because Rosetta 2 executes the x86_64 slice. - The runner's glibc is the ceiling on what the artifacts may require. The
binaries link against it, so the runner bounds compatibility. The MEASURED
requirement of the shipped binaries is lower than the runner's own version:
the highest
GLIBC_*symbol version across the Electron binary and every bundled.sois 2.34, which covers Ubuntu 22.04+, Debian 12+, Fedora, CentOS Stream 9 and Amazon Linux 2023, and excludes Ubuntu 20.04, Debian 11 and Amazon Linux 2. Read the requirement withobjdump -T <binary> | grep -oE 'GLIBC_[0-9.]+' | sort -uV | tail -1rather than assuming it equals the runner's glibc. The AppImage links against it, which is why both Linux legs stay on 22.04 (glibc 2.35) rather than moving to 24.04 (2.39) — the newer floor would exclude AL2023, Debian 12 and RHEL 9.
Building your own package locally. make desktop needs no arch flags: it
detects the host and emits an AppImage for it, so running it on an ARM box
produces the aarch64 build with no CI involved. Filenames are arch-qualified
(KiroCrew-<version>-<arch>.AppImage) so several arches can sit in one directory
without overwriting each other. To validate a packaging change against every
platform without publishing anything, dispatch build-desktop.yml manually —
it builds the full matrix and uploads artifacts, with no publish lane attached.
Anything you distribute for macOS should be the universal DMG — the host-arch build is a local-machine artifact.
Prerequisite: Rosetta 2 on the build machine
(softwareupdate --install-rosetta --agree-to-license) — the x86_64 PBS
interpreter runs under Rosetta during the build (pip install + verification).
The script preflights this (arch -x86_64 /usr/bin/true) and aborts with the
softwareupdate hint if missing.
How it works — universal shell + dual embedded backends:
- The Electron shell binaries (
Contents/MacOS/,Frameworks/) are lipo-merged fat binaries via electron-builder's--universaltarget. - The PBS backend tree cannot be lipo-merged (thousands of files, no universal2 PBS — see below), so the app ships two complete backend trees and picks one at launch:
KiroCrew.app/Contents/
├── MacOS/ + Frameworks/… ← fat binaries (arm64 + x86_64)
└── Resources/backend-dist/
├── kirocrew-backend-arm64/ ← full PBS bundle, arm64
└── kirocrew-backend-x64/ ← full PBS bundle, x86_64
The build runs the normal backend steps twice: natively for
kirocrew-backend-arm64/, then again with an x86_64 PBS interpreter
(uv python install cpython-3.12-macos-x86_64-none, executed under Rosetta)
for kirocrew-backend-x64/. The frontend is built once (arch-independent).
Each backend passes the same self-containment gate as a per-arch build — the
x64 gate doubles as proof the bundle runs under Rosetta. In
website/electron/package.json, build.mac.x64ArchFiles allowlists
backend-dist/** (single-arch Mach-O files inside a universal app are
intentional there), and extraResources ships the backend-dist/ directory
wholesale so single- and dual-backend layouts both package.
Renaming
backend-dist/is load-bearing at runtime. The backend detects "am I the bundled interpreter?" viaplatform_compat.is_bundled_interpreter()(BUNDLED_BACKEND_DIST_DIRNAME), which is what stopspipfrom writing into the signed bundle during app builds.test/test_platform_compat.pypins that constant to bothextraResourceshere andpackaging/build-desktop.sh, so a rename fails a test — update the constant and the packaging layer in the same change.
Trade-off: the DMG carries two full Python backend trees, so it is roughly 2× the size of a per-arch DMG — expect ~350–400 MB. That is the price of one artifact + one update feed; a per-arch feed split was explicitly deferred.
Verify a universal build:
V=<version>
hdiutil attach -nobrowse -readonly "website/electron/dist/KiroCrew-$V-universal.dmg"
APP="/Volumes/KiroCrew $V-universal/KiroCrew.app"
# 1. The shell binary is fat:
lipo -archs "$APP/Contents/MacOS/KiroCrew"
# → x86_64 arm64
# 2. EACH backend carries the matching interpreter:
file "$APP/Contents/Resources/backend-dist/kirocrew-backend-arm64/bin/python3.12"
# → …executable arm64
file "$APP/Contents/Resources/backend-dist/kirocrew-backend-x64/bin/python3.12"
# → …executable x86_64
hdiutil detach "/Volumes/KiroCrew $V-universal"
(The build script performs these lipo -archs / file checks itself as
post-gates, plus a resolver-agreement gate asserting find-bin.js resolves
the arch-suffixed launcher.)
CI: the macos-14 (Apple Silicon) entry in build-desktop.yml runs
make desktop (universal by default on macOS — GitHub's arm64 macOS runners
include Rosetta 2)
and uploads a single unsigned-build-darwin-universal artifact. Everything
downstream (codesigning both slices, notarization, stapling, the update
feed) is arch-indifferent: the feed schema is unchanged, latest-mac.yml
points at the one universal zip, and installed arm64 apps auto-update onto
it seamlessly. No Intel runner and no per-arch feed split are needed.
Why no true universal2 backend?
A genuinely lipo-merged (universal2) backend stays off the table: there is
no universal2 python-build-standalone distribution, the backend tree is
thousands of files (a fragile file-by-file merge with no tool support), and
not all native dependencies publish paired wheels to merge (numpy, aiohttp,
lxml, PyYAML…). The dual-backend layout above is how universality is achieved
instead — two single-arch trees, selected at launch by process.arch.
Refreshing / cleaning the DMGs
The dist/ directory is not cleaned between builds, so old artifacts pile up
(e.g. a KiroCrew-1.0.0.dmg from before a version bump, or a stale mac/
app-staging dir). After a version change or a re-build, remove the stale ones so
only the current set remains:
cd website/electron/dist
rm -f KiroCrew-<old-version>*.dmg # stale DMGs from a prior version
rm -rf mac mac-arm64 mac-universal* # app-staging dirs (regenerated each build)
rm -f builder-debug.yml
The desktop app's version comes from website/electron/package.json (version)
— keep it in sync with the backend version in pyproject.toml. When you
bump one, bump the other and the root version fields in
website/electron/package-lock.json (the top-level version and
packages[""].version, NOT the dependency entries that coincidentally share a
version), or npm ci will complain about a lock mismatch.
npm registry (system-configured): the
.npmrcfiles deliberately do NOT pin a registry.npm ciinherits whatever registry the machine's~/.npmrcor environment configures, so mirrors and private registries work for builders who cannot reachhttps://registry.npmjs.org/. If your configured registry lacks a public package or its auth token expired, fix your registry config rather than adding a pin back.
Build pipeline
make desktop runs bash packaging/build-desktop.sh, which executes the
pipeline end-to-end:
1. Build the React dashboard (npm) → website/dist
2. Provision a python-build-standalone interpreter → via uv python install
3. pip-install kiro_crew + deps into the bundled interpreter
4. Stage the dashboard into the package's static dir
5. Prune caches/tests/unused stdlib to shrink bundle
6. Package with electron-builder → website/electron/dist/ (DMG / AppImage / NSIS)
On macOS (universal by default) the pipeline repeats steps 2–5 once per
architecture — natively into kirocrew-backend-arm64/, then with an x86_64
PBS interpreter under Rosetta into kirocrew-backend-x64/ — and step 6
packages with electron-builder --mac --universal. With UNIVERSAL=0 (and
always on Linux) steps 2–5 run once for the host arch into the unsuffixed
kirocrew-backend/.
Step by step:
- Frontend — in
website/, runsnpm ci(ornpm install) +npm run build, then copieswebsite/distintosrc/kiro_crew/static/dist. The script aborts ifwebsite/dist/index.htmlis missing. - PBS interpreter — uses
uv python install cpython-3.12to provision a self-contained python-build-standalone interpreter. PBS interpreters use@executable_path-relative dylib references, making the bundle portable across machines without needing the same system Python. - Install into bundle — copies the PBS interpreter into
website/electron/backend-dist/kirocrew-backend/, removes theEXTERNALLY-MANAGEDmarker, then runspip installwithPYTHONNOUSERSITE=1to force the full closure into the bundle. The local speech recogniser and its runtime dependencies are required on Windows, Linux x64/arm64, and macOS Apple Silicon; a missing binary wheel fails the release build. macOS Intel is the sole unsupported exception. - Stage dashboard — copies the built SPA into the bundled
kiro_crew/static/distinside site-packages. - Prune — removes
__pycache__, test dirs, and unused stdlib modules (tkinter, idlelib, etc.) to shrink the bundle. - Package — in
website/electron/, runs electron-builder to produce the installer(s) inwebsite/electron/dist/. The macOS DMG and Windows NSIS wizard consume the checked-in artwork underpackaging/installer-assets/. The build reads only the committed rasters; edit the SVG sources beside them and runnode packaging/installer-assets/build-assets.mjsto regenerate the TIFF and BMPs. That script is the only place that knows the output shapes the two installers require — a multi-representation TIFF for Retina, and 24-bit BMPs, which NSIS cannot read at the 32-bit depthsipsemits.
Build flags
The script honors these environment flags:
| Flag | Effect |
|---|---|
UNIVERSAL=0 | macOS: opt out of the universal default — host-arch-only build (faster local iteration; the only option on an Intel Mac). Universal (UNIVERSAL=1) is the macOS default; Linux is always host-arch |
SKIP_FRONTEND=1 | Reuse an already-built website/dist |
SKIP_ELECTRON=1 | Stop after the bundled backend (no electron-builder) |
The bundled backend (python-build-standalone)
The build produces a self-contained Python interpreter with all dependencies
installed, located at website/electron/backend-dist/kirocrew-backend/
(per-arch mode) or …/backend-dist/kirocrew-backend-arm64/ +
…/kirocrew-backend-x64/ (universal mode — electron-builder ships the whole
backend-dist/ directory as extraResources, so both layouts package the
same way). Key details:
- Interpreter is a python-build-standalone CPython 3.12 with
@executable_path- relative dylib references (genuinely portable, no system Python dependency). - Entry point is
bin/kirocrew— a shell script that execsbin/python3.12 -s -m kiro_crew "$@". - Stdlib probes verified —
stdlib_probe_gatefails the build if any package the launcher's readiness check probes is missing from the pruned tree, so a drifted probe list breaks the build instead of every user's launch (see How the app finds and launches the backend). - Self-containment verified — the build script runs
PYTHONNOUSERSITE=1 bin/python3.12 -m kiro_crew --versionfollowed byPYTHONNOUSERSITE=1 bin/python3.12 -c 'import kiro_crew.cli'to catch any missing dependency before packaging. Bare--versionis a pre-dispatch fast-path (seedocs/system-specs/modules/cli.md), so the import probe is the half that resolves the chain. - Local dictation runtime bundled — supported desktop builds include
pywhispercpp, the platformimageio-ffmpegexecutable used for compressed recordings, and all transitive runtime dependencies. The build imports the recognizer and executes the exact packaged decoder before publishing — and distinguishes a decoder that fails to AUTHENTICATE, which fails the build, from one that authenticates but will not run on the build host, which warns and ships (see stt-streaming). Model weights are deliberately excluded from the installer: the user selects a model and clicks Download now, with no package manager or separate dependency step. Intel macOS is the unsupported recognizer exception. Every bundled executable ships uncompressed — the Apple notary service decompresses archive members and rejects an unsigned executable found inside one, which fails the whole macOS release (see stt-streaming for how the runtime then authenticates a decoder whose bytes signing rewrote). - Dashboard bundled — the SPA is staged into
lib/python3.12/site-packages/kiro_crew/static/dist/inside the bundle. - Pruned —
__pycache__, test dirs, and unused stdlib (tkinter, idlelib, turtledemo, ensurepip, lib2to3) are removed to shrink the bundle.
How the app finds and launches the backend
When the app starts, main.js composes the
desktop lifecycle and delegates gateway ownership to
gateway-supervisor.js. The
supervisor first checks whether a gateway is already running. An existing
gateway—including a local SSH forward to a remote gateway—is reused. Otherwise
it locates the backend binary via
find-bin.js, spawns it as kirocrew gateway --no-open, polls /api/status, and loads the dashboard once it is
healthy.
Host-runtime discovery stays behind the same main-process ownership boundaries.
The wsl:detect handler in
ipc-registrar.js fails closed unless
the sender has the fixed primary origin,
window-lifecycle.js proves that
its window uses a local gateway rather than a configured tunnel, and
gateway-supervisor.js
positively identifies the primary listener as Kiro Crew or its service. A
manual SSH tunnel, foreign listener, unbound port, or unavailable owner probe is
therefore refused; only then may
wsl-detection.js run the trusted
system wsl.exe path.
Before spawning a bundled backend the shell checks that the bundle's Python
stdlib is fully on disk
(bundle-integrity.js). The
Windows NSIS installer extracts backend-dist/ incrementally and launches the
app as it finishes (runAfterFinish), so a launch inside that window finds
python.exe present while late-alphabet stdlib packages are not — the
interpreter then dies on from urllib.parse import … reached through
pathlib, which reads as a corrupt install rather than an unfinished one. The
check probes stdlib packages spread across the alphabet — via each one's
__init__.py, since an extractor creates a directory before filling it and a
top-level .py file lands with the early batch — and, when any are missing,
reports "still being installed" through the normal gateway-failure dialog, whose
Retry succeeds once extraction completes. It stays silent for the legacy
flat layout, which carries no interpreter tree to verify.
That pre-spawn check cannot be complete, and does not pretend to be: extraction
order within a package is not the app's to control, so import zoneinfo can
still fail moments after zoneinfo/__init__.py appears. A second, sound check
backstops it. The two are not redundant — the pre-spawn probe is preventive but
unsound, the backstop sound but after-the-fact, and each covers what the
other cannot. Refusing before spawn() keeps a doomed interpreter from running
module-scope work against the live data home (it creates the home and
.local_secret, and writes bytecode caches) and from failing in messier ways than
a clean ModuleNotFoundError while extraction is still writing underneath it;
the backstop can only ever explain a crash that already happened.
When a spawn dies on a stdlib import, the launch log is read and the failure reclassified as an unfinished install. Two traceback forms are matched, because a half-written package does not report the obvious one:
ModuleNotFoundError: No module named 'urllib'— the package (or, for a dotted name, a submodule of a package that did land) is absent.ImportError: cannot import name '_tzpath' from partially initialized module 'zoneinfo'— the package's__init__.pyarrived before its siblings. This is what CPython actually raises in that case, verified against the shipped interpreter, and it is precisely the state the pre-spawn probe cannot see.
Three conditions keep it from excusing anything else. Judgement is by the
top-level package name, which must be in the stdlib set, so a missing
third-party or first-party module (a genuine packaging defect) is never relabelled.
Only a bundled backend qualifies — a user's own install or a PATH kirocrew
failing on a stdlib import is a broken environment, and "wait for the installer"
would be misleading advice there. And only the current launch attempt is read:
the log is append-only across launches, so the text is sliced from the last spawn
marker (SPAWN_MARKER, owned by bundle-integrity.js and logged by
gateway-supervisor.js so writer and reader cannot drift). Without that, an
older traceback could relabel
this attempt's unrelated failure — a SIGKILL, or a bound port whose real remedy
is force-stop rather than a bare Retry — and show a reassuring dialog over a live
fault. When the marker has scrolled out of the tail, attribution is unknowable and
the check declines.
Why not an installer-written completion sentinel? It looks like the obviously
sounder mechanism — the installer knows exactly when extraction finished, and
installer.nsh could write a marker from customInstall. It is rejected because
nsis.perMachine is false and updates run the new version's installer over the
existing install directory: after the first update the tree carries a sentinel
written by the previous installer, which cannot be told apart from a valid one
while a newer build is still extracting. That is precisely the reported failure (an
update, not a fresh install), so a naive sentinel would assert "complete" during the
exact race it was added to close. A sound version must be version-scoped, rewritten
atomically per install, and compared against the running app's own version. It would
also be Windows-only — the DMG and the Linux packages have no customInstall — so
it is an addition on top of the probe, never a replacement for it.
The build enforces the other direction: stdlib_probe_gate runs after
pruning in both backend build paths and fails the build if any probed package
is absent from the tree just built. A probe list that drifts from the shipped
stdlib (a Python bump turning a package back into a module, a rename, or a new
prune) would otherwise refuse every launch of a healthy app — a permanent
failure worse than the transient one the gate prevents. Like resolver_gate it
needs node, and logs a visible SKIP rather than failing when none is on PATH,
so a node-less build environment still produces a bundle (unvalidated).
The gateway-hosted dashboard then checks both prerequisites needed by the ACP provider:
- It discovers
kiro-cliin the inheritedPATH,~/.local/bin,~/.cargo/bin, Homebrew locations, or the macOSKiro CLI.appbundle. - It verifies the first candidate selected by the shared ACP resolver with
kiro-cli --version. A broken or untrusted higher-priority candidate blocks readiness instead of approving a later binary that ACP would not launch. - It verifies authentication with
kiro-cli whoami.
If either check fails, the shared React setup gate appears in both the desktop
shell and browser dashboard. Kiro Crew performs neither setup step: the gate
links out to https://kiro.dev/cli/ to obtain the CLI, and names the commands
the user runs to sign in — kiro-cli login for a personal account, or
kiro-cli login --use-device-flow --license pro for organization SSO. Both
tiers are shown because the browser portal the bare command opens offers a free
Builder ID alongside organization SSO, so an SSO user who picks the wrong one
authenticates successfully and only discovers the mismatch later as models
missing from their account. The gate's only control is Check again, which
re-probes the host; it opens the dashboard once kiro-cli whoami succeeds.
An installed candidate that cannot start is shown as needing repair rather than
as merely signed out; one that runs is directly usable for sign-in regardless of
install source (toolbox, Homebrew, winget, the official installer, or a
self-updated bundle) — trust is "the CLI runs, and it has a valid login", not
where it was installed. A broken existing macOS app bundle or Linux user-local
binary is repaired through the official interactive guide when the upstream
installer requires terminal confirmation before replacing it. Installation and
sign-in never start silently in the background. Setup subprocesses receive a
minimal allowlisted environment rather than the desktop shell's credentials;
version probes use the strict OS sandbox and hide every known Kiro identity
store. whoami and device-login run for any runnable candidate; they use a
standard sandbox with a temporary home containing only Kiro identity token
files, so unrelated AWS, SSH, GitHub, Kubernetes, and Kiro Crew state remain
unavailable, and POSIX auth still executes a private snapshot of the exact
resolved bytes. Timed-out commands signal a POSIX process group only
while its leader still anchors that identity; on Windows, exact retained process
handles terminate observed descendants without trusting recycled PIDs. Cleanup
finishes before the gateway permits a retry.
Hosting setup in the gateway provides one implementation and one UI for the
desktop app, local browser, remote browser, Linux, and Windows.
Native window chrome
The dashboard's 42px top bar is also the window titlebar on macOS and Windows. macOS insets the native traffic lights on the left. Windows uses Electron's title-bar overlay to retain native minimize/maximize/close controls on the right. The application menu rests as a compact hamburger on the left. Opening it shows the File submenu and expands File/Edit/View/Connection/Window/Help inline; hovering another label replaces the submenu without ending the menu session. Escape, an outside click, selecting a command, or moving focus to another window closes the popup and collapses the labels back to the hamburger. The menu surface uses the dashboard theme because native Windows popups capture window input and cannot support hover switching; a narrow IPC bridge keeps command execution and standard Electron roles in the main process. When a remote instance is connected, the instance switcher shares the same bounded left region as the menu: it is a single trigger naming the instance on screen (see InstanceTabBar's SwitcherMenu), not a row of per-instance tabs, so it costs constant width whether the menu is collapsed to a hamburger or expanded to full labels. The centered command palette yields that region rather than the reverse — the correct priority while the menu is open is labels > instance status > an idle search affordance — and the palette remains reachable through its keyboard shortcut even while hidden. The command-palette trigger is positioned from the window midpoint rather than the remaining flex space, so asymmetric menu and status controls do not shift it. Linux retains the window manager's native frame and menu bar.
Focus mode: verify these seams after an Electron or Radix bump
Focus mode (hide the shell chrome behind hover) rests on three mechanisms that
key on behavior no API contract guarantees, and each fails silently — the
unit tests mock these seams, so a broken one still passes CI and only manual
macOS testing catches it. Run this short checklist whenever you bump Electron or
Radix (website/electron/package.json, @radix-ui/* in website/package.json):
- Toggle focus mode, then drag the revealed header to move the window.
Exercises the drag-region re-send in
website/electron/focus-chrome.js: Electron'ssetWindowButtonVisibilitymutates the window styleMask and drops the renderer's declared-webkit-app-region:dragregions, so the renderer re-declares them by briefly adding a 1px drag element. If a bump changes when Chromium re-sends the region set, the revealed header selects text instead of moving the window. - Peek the header, then move the pointer down into the content. The header
should close. Peek the rail, then move the pointer right past the rail track —
it should close too. Exercises the positional close in
website/src/App.tsx(departWhen: clientY > 48for the top peek,clientX > 248for the rail): the revealed header doubles as the drag surface and a drag region eats pointer events before hit-testing, so the close is driven by pointer position, not bymouseleave. If a bump changes hover/pointer-event delivery, the peek sticks open or never opens. - Peek the header, then open the instance switcher. The header must stay on
screen while the switcher menu is open. Exercises the header-pin heuristic in
website/src/App.tsx: Radix portals the menu todocument.body, so the pin rides on a[aria-haspopup][aria-expanded="true"]query against the header rather than DOM containment. If a Radix bump changes the ARIA a trigger emits (aria-haspopupabsent, oraria-expanded="true"emitted by default with nothing open), the header either slides away under the open menu or pins permanently from first paint.
find-bin.js — locating the binary
findKirocrewBin() checks well-known paths in order and returns the first
executable it finds, falling back to bare kirocrew on PATH. The running
process's CPU architecture (process.arch, injected as a parameter) selects
the matching backend in a universal app:
<resourcesPath>/backend-dist/kirocrew-backend-<arch>/bin/kirocrew, then<__dirname>/…— the arch-suffixed PBS backend inside a universal packaged.app(or unpackaged in development), where<arch>isarm64orx64perprocess.arch(a fat Electron shell runs as exactly one slice, soprocess.archis the native arch of the Mac — Apple Silicon loadskirocrew-backend-arm64/, Intel loadskirocrew-backend-x64/). Ranked above the unsuffixed layout so a universal bundle never falls back to a wrong-arch tree; per-arch bundles don't ship these dirs, so the probes miss and fall through.<resourcesPath>/backend-dist/kirocrew-backend/bin/kirocrew, then<__dirname>/…— the unsuffixed fallback: the bundled PBS backend inside a per-arch packaged.app(or unpackaged in development).<__dirname>/../bin/kirocrew- Well-known install paths under
$HOME(e.g.~/.local/bin/kirocrew,~/.kirocrew-app/.venv/bin/kirocrew). - Bare
"kirocrew"(resolved viaPATH).
The function is pure — fs, os, path, process.resourcesPath,
__dirname, and the arch are injected — so both arch branches are
unit-testable without mocking globals.
gateway-supervisor.js — owning the gateway lifecycle
- Ensures
KIROCREW_HOME(default~/.kiro/crew, overridable via theKIROCREW_HOMEenv var) exists, then spawns the backend with["gateway", "--no-open"]. If a real pre-move~/.kirocrewdirectory exists, the shell reads its startup config first while the backend performs the one-time migration; token lookup then falls through to the canonical home. A clean install never creates the legacy directory. - Honors the
KIROCREW_PORTenv var for the dashboard port (default5476, validated to1–65535).BACKEND_URL/ health checks target that port. - Sets
KIROCREW_PROJECT_DIRto the Electron app's parent directory so the bundledagents/andskills/are discovered. - On every desktop platform, pins
PYTHONUTF8=1andPYTHONIOENCODING=utf-8:backslashreplaceat the Electron-to-Gateway spawn boundary. This applies before CPython constructs redirected stdout/stderr and is inherited by the Gateway'sos.execvsuccessor plus its MCP/session children. Consequently the initial launch, Tailnet/explicit restart, update and stale-asset re-exec, and Electron liveness respawn all use the same UTF-8 contract instead of falling back to the Windows ANSI code page or an incompatible inherited POSIX encoding override. - Leaves the inherited child
PATHunchanged. The gateway prerequisite service probes supported Kiro CLI locations independently — including the Windows per-user install at%LOCALAPPDATA%\Kiro-Cli— so desktop launches find user-local installations without mutating the shell environment or requiring the already-running gateway to inherit an installer-updatedPATH. window-lifecycle.jshides the app to the tray on window close; the composition root delegates quit-time gateway teardown to the supervisor, which performs the graceful shutdown and signal escalation contract.
Code signing & notarization (macOS)
An unsigned .app/DMG is quarantined by Gatekeeper and shows "Kiro Crew is
damaged and can't be opened" when downloaded on another Mac. To distribute a
DMG that opens cleanly you must sign it with a Developer ID Application
certificate and notarize it with Apple. (Local builds without credentials
still work — they produce an ad-hoc–signed DMG you can open on the build machine
after right-click → Open or xattr -dr com.apple.quarantine KiroCrew.app.)
The build is already wired for this — website/electron/package.json enables
hardenedRuntime with build/entitlements.mac.plist, and the
scripts/notarize.js afterSign hook notarizes when credentials are present and
silently skips when they aren't. You only supply the secrets at build time via
env vars (nothing is committed):
For release builds, the unsigned Electron-built DMG is retained only as a
layout template. packaging/signing/build-dmg.sh converts it to a writable
image, verifies that its app name matches the signed/stapled app, replaces that
one bundle, shrinks and recompresses the image, and then the release workflow
signs and notarizes the resulting DMG. Recreating the image from a plain folder
would discard Finder's volume-bound background reference.
# 1. Signing identity — a Developer ID Application cert exported as .p12
# (Xcode → Settings → Accounts, or developer.apple.com → Certificates).
export CSC_LINK=/abs/path/DeveloperIDApplication.p12 # or its base64
export CSC_KEY_PASSWORD='<p12 export password>'
# 2. Notarization credentials — EITHER an App Store Connect API key …
export APPLE_API_KEY=/abs/path/AuthKey_XXXXXXXXXX.p8
export APPLE_API_KEY_ID=XXXXXXXXXX
export APPLE_API_ISSUER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# … OR an Apple ID + app-specific password (appleid.apple.com → Sign-In
# & Security → App-Specific Passwords):
export APPLE_ID='you@example.com'
export APPLE_APP_SPECIFIC_PASSWORD='abcd-efgh-ijkl-mnop'
export APPLE_TEAM_ID=XXXXXXXXXX
# 3. Build — electron-builder signs, the hook notarizes + staples.
make desktop
Verify the result: spctl -a -vv "KiroCrew.app" should report
source=Notarized Developer ID and codesign -dv should show your Team ID
(not Signature=adhoc).
Requires a paid Apple Developer account ($99/yr) for the Developer ID cert and notary access. Without one, distribute via Homebrew cask or instruct users to clear the quarantine flag.
macOS folder-access (TCC) prompts
macOS gates ~/Downloads, ~/Documents, ~/Desktop, ~/Pictures, ~/Movies
and ~/Music behind TCC (Transparency, Consent and Control). The first time
an app reads one of them, macOS shows a modal "Kiro Crew would like to access
files in your Downloads folder", and consent is recorded per (app, folder)
pair — so an operation that incidentally touches three of those folders
produces three separate prompts, one after another.
Nothing Kiro Crew does at startup needs those folders. They were only ever
reached incidentally, by the @-mention file picker's filesystem walk when it
fell back to bare $HOME as a catch-all search root (no project selected). That
single unscoped walk descended into Downloads/Documents/Desktop and
tripped one prompt each.
Those walks now prune the TCC-protected folders when — and only when — the walk
root is $HOME itself
(platform_compat.tcc_protected_dirs_for_walk, applied in
dashboard/file_index.py and the /api/file-search fallback). Two consequences
worth knowing:
- Explicit access is unaffected. If you point Kiro Crew at a project inside
~/Documents, browse to~/Downloadsdirectly, or even name$HOMEitself as the project, the root is scoped by definition and is walked in full — only the unscoped$HOMEfallback prunes. macOS still shows its own one-time prompt for that deliberate access — that is the expected OS contract, and granting it once is enough. - Pre-declaring usage strings would not have fixed this. Adding
NSDocumentsFolderUsageDescriptionand friends toInfo.plistonly changes the wording of each prompt; it does not reduce the count. Not reading the folders is what removes the prompts.
A signed, stable bundle identity matters here too: TCC keys consent off the app's code-signing identity, so an ad-hoc/unsigned local build can be treated as a different app after a rebuild and re-prompt for grants you already gave. Distributing the signed + notarized DMG (above) keeps grants sticky across updates.
Device resources (microphone) need an ENTITLEMENT, not just a usage string
Folder access above needs only consent. A device resource is different: under
the hardened runtime the capability is granted by a com.apple.security.device.*
entitlement, and the Info.plist usage string only supplies the prompt's
wording. Get this wrong and the failure is deeply misleading:
Symptom: voice input reports "Microphone permission denied" instantly, no system prompt ever appears, and there is no Kiro Crew row under System Settings › Privacy & Security › Microphone to switch on. The same mic works in Chrome at the same origin on the same machine.
Because under the hardened runtime the microphone requires
com.apple.security.device.audio-input in addition to the usage string —
without it access is refused and no prompt appears, so there is nothing to
consent to and nothing to toggle. The entitlement is a Hardened Runtime
Resource Access capability (Xcode's "Audio Input" checkbox), not an
App-Sandbox-only key: this app is not sandboxed, and neither are Chrome, Slack
or Zoom — all three are hardened-runtime, non-sandboxed, and all three ship
audio-input. The usage string is not a substitute; both are load-bearing.
It is worth being precise about where the capability lives, because the
intuitive answer is wrong: in Chromium the audio capture runs in the browser
(main) process — the renderer only requests it over IPC — and TCC attributes
access to the responsible main bundle. Chrome's and Slack's Renderer helper
apps carry no audio-input entitlement at all, and their microphones work. So the
main bundle's entitlements is what matters; entitlementsInherit is set to the
same file so helpers keep their JIT/library-validation keys.
Two things follow, and both are pinned by website/electron/test/packaging.test.js:
- There are TWO signing lanes reading TWO different files. electron-builder
signs local/dev builds with
website/electron/build/entitlements.mac.plist; the release lane signs withpackaging/signing/Entitlements.entitlements. An entitlement added to one and not the other ships a broken bundle on the other lane — keep them in sync. - The camera is deliberately absent.
permission-handler.jsdenies any request that explicitly asks for video, so requesting the camera entitlement would widen the TCC surface for a capability the app never uses.
The prompt is also one-shot: once a user denies the mic, macOS never asks
again. So permission-handler.js consults
getMediaAccessStatus('microphone') on each request and branches —
not-determined asks in-context (right when the user clicks the mic, rather than
spending the single prompt at launch on an unrelated moment), while
denied/restricted opens the Privacy pane via showMicPermissionDialog(),
since the OS will not re-prompt on its own. Every failure mode in that probe
fails open, so diagnosing permissions can never itself be what breaks the mic.
The sinks (breadcrumb log, recovery dialog) are deliberately kept off the
answer path: an earlier revision had them inside the promise chain upstream of a
fail-open .catch, so a throwing logger turned a user's explicit refusal into
a grant. Auditing must never be able to change a permission verdict.
Developer gotcha: a stale TCC row survives a fix
TCC rows are pinned to the app's code-signing identity (cdhash), not just its
bundle id — and ad-hoc local builds share one collapsed Identifier=Electron
identity. So a machine that ran a dev build can hold a Microphone row for
com.amazon.kiro.crew whose csreq matches a cdhash the Developer-ID release
can never satisfy. The row reads granted in the TCC database and is still never
honored, which looks exactly like the entitlement bug and survives fixing it.
If the mic still fails after a rebuild, clear the row and let the app re-prompt:
tccutil reset Microphone com.amazon.kiro.crew
This is also why distributing the signed + notarized DMG matters (above): a stable identity is what keeps grants sticky instead of silently orphaning them.
Local network access needs a USAGE STRING, and no entitlement exists for it
macOS 15 (Sequoia) added local-network privacy for every app, sandboxed or
not. The mic's lesson does not transfer: there is no device.* entitlement to
add here, and adding one of the neighbouring network keys makes things worse.
This resource is TCC-only, and NSLocalNetworkUsageDescription is the entire
declaration.
Symptom: an agent's shell command connects fine to the default gateway (
192.168.x.1) and to any public host, but every other LAN address — a NAS, an IoT device, another dev box — fails instantly with errno 65 (EHOSTUNREACH, "No route to host") in ~0.000s rather than timing out.pingand ARP to the same host succeed, so it reads as a routing fault. There is no Kiro Crew row under System Settings › Privacy & Security › Local Network, andtccutil reset LocalNetwork com.amazon.kiro.crewfails because no TCC record exists to reset.
The gateway-works / everything-else-fails split is the signature of the TCC gate,
not of the network. With no declared intent macOS creates no
kTCCServiceLocalNetwork record, so there is no prompt to answer and no toggle to
flip — the same dead end as the mic, reached by a different mechanism.
Three neighbouring keys look like the fix and are not:
com.apple.developer.networking.multicastcovers multicast and broadcast, requires an Apple-granted provisioning profile, and breaks signing when requested unprovisioned. Plain unicast LAN access does not need it.com.apple.security.network.clientonly means anything under App Sandbox, which this bundle does not use.NSAllowsLocalNetworking(which the bundle already carries) is an App Transport Security key that relaxes HTTPS requirements for local hostnames. It has nothing to do with the TCC gate — an easy one to mistake for a fix, since it is already present in a bundle that cannot reach the LAN.
website/electron/test/packaging.test.js pins both directions: the usage string
must be declared with real copy, and neither entitlement may appear in either
signing lane.
Why the CLI gateway is not affected the same way
Apple exempts several launch contexts from local-network privacy: daemons started
by launchd, anything running as root, and command-line tools run from Terminal
or over SSH, including every child process they spawn. So a gateway started with
kirocrew gateway from a terminal reaches the LAN normally, while the same agent
command run under the desktop app is gated by the app bundle's TCC record. That
asymmetry is a useful triage question ("how did you start the gateway?") and a
usable workaround, not evidence that the app is fine.
One caveat worth knowing before concluding the usage string alone fixed it: agent
shell commands are wrapped by sandbox_exec_argv in src/kiro_crew/sandbox.py,
which execs the target through /usr/bin/sandbox-exec and replaces the process
image. The Seatbelt profile itself is (allow default) plus filesystem denies and
carries no network rules, so the sandbox does not block sockets — but whether
TCC's responsible-process attribution still lands on the app bundle across that
exec has to be confirmed on a real macOS 15 host rather than reasoned about.
Externally-managed installs (repackagers)
A distro or enterprise packager that redistributes the desktop app through its own package manager owns the install's update lifecycle: the package manager replaces the whole install, so the built-in auto-updater would fight it (each overwriting the other's bytes) and its feed check would compare against releases the packager never ships.
Such a packager opts out by dropping an EXTERNALLY-MANAGED marker file
(named after the PEP 668 precedent) into the packaged resources directory —
the same outside-asar surface that carries package-type and backend-dist
(Contents/Resources/ on macOS, resources/ on Linux and Windows). Its
presence alone disables the updater: the feed is never contacted, and
Settings → About hides the release-channel switcher (the lanes it offers are
ones the packager never reads). The body is optional JSON metadata for the
About panel:
{
"managedBy": "your package manager's name",
"updateCommand": "the command users run to update"
}
managedBy names the owning system in the "updates are managed by …"
message; updateCommand renders as a copyable command. An empty or
unparsable body still counts as managed — an operator who dropped the file
gets the safe behavior even when the metadata is wrong.
The body is only read when the marker's provenance can be established:
neither the marker nor its directory may be owned by the account the app runs
as, and neither may be group- or world-writable. Ownership rather than current
mode bits, because a POSIX owner can always chmod +w back — a marker the app's
own user owns is one a prompt-injected agent shell could have planted and then
made read-only. updateCommand/checkCommand are executed through a shell on
the managed auto-update path, so a marker in a user-owned resources directory
(Homebrew, pip --user, ~/Applications) is treated as a bare marker: managed,
updater off, no metadata and nothing to run. Packagers that want the managed
commands honored must install the resources directory root-owned.
The commands run with a constructed environment, not the app's own. Only an explicit pass-through set reaches them — USER, LOGNAME, TZ, TMPDIR, the LANG/LC_* locale vars, and the proxy vars — plus a narrowed system-only PATH and cwd=/. HOME is deliberately excluded: Python derives its user-site directory from it, so passing it through would let a planted sitecustomize.py run on every python start. Everything else is absent by construction, because shell: true means a shell interprets the command and a shell reads its environment as code: the loader family (LD_*/DYLD_*), the interpreter family (PYTHON*, NODE_OPTIONS), the startup files (BASH_ENV, ENV), the tracing pair (SHELLOPTS plus a command-substituting PS4), word splitting (IFS), and exported shell functions (BASH_FUNC_*, which shadow a command name outright). A packager whose updater needs any other variable must set it inside its own command rather than relying on inheritance.
On Windows a loose marker's commands are never honored. There is no POSIX
owner to read and access(W_OK) does not model ACLs, so no honest provenance
verdict exists; the check fails closed by declaration and every loose Windows
marker is treated as bare (managed, updater off). A Windows packager either
drives updates with its own installer or bakes the marker in (next).
Baking the marker into the app (editions)
The provenance rule above refuses every install the app's own user owns, which
is every per-user package manager (a Toolbox, Homebrew, ~/Applications), and
can never pass on Windows. An edition — a build that IS produced by the
package manager's owner — does not need to drop a file beside the app after the
fact; it declares the marker at build time:
KIROCREW_MANAGED_INSTALL_MARKER=/path/to/marker.json bash packaging/build-desktop.sh
build-desktop.sh validates the file (a JSON object of string fields
managedBy / updateCommand / checkCommand, under 8 KiB, with an
updateCommand — a marker that disables updates while offering none fails the
build rather than shipping silently) and copies it to
website/electron/EXTERNALLY-MANAGED, which electron-builder packs into
app.asar next to main.js. The running app reads that copy first and
trusts it without any ownership probe, on every platform: it is part of the
application's own code, so anyone positioned to rewrite it is already
positioned to rewrite the code that reads it, and no file-ownership check could
add to that. On macOS the baked copy is additionally sealed by codesign. A baked
marker outranks a loose one when both exist — a build-time declaration by the
edition that produced the binary beats a file dropped next to it later.
The default build ships no baked marker (the file is gitignored and removed at the start of every build), so a plain checkout keeps the loose-marker contract exactly as described above.
The commands themselves still run under the constructed environment described
above: no app environment variable reaches them — not HOME, and not
anything the edition's own wrapper exported before launching the app. So a
command must not rely on $HOME or ~ expanding (derive the home directory
from USER, which is passed through, or name paths that do not depend on it),
and must not reference a variable it expects the app to have inherited. On
Windows that failure is silent: cmd.exe leaves an undefined %VAR% in the
command line as the literal text %VAR%, not as an empty string, so a
wrapper argument such as "%SOME_VAR%" arrives as that string. The one value
the constructed environment does derive for the command is
KIROCREW_MANAGED_ARGV0 — the running app executable's absolute path
(process.execPath, taken from the process, never from the environment) — so a
wrapper that verifies its relaunch target has a trustworthy answer without any
inheritance.
For local testing, the KIROCREW_EXTERNALLY_MANAGED env var points at a marker
file (any other non-empty value marks the install managed with no metadata).
It is honored on unpackaged builds only — a packaged app ignores it, because
its launch environment is user-writable.
The gateway has the matching seam for its own surfaces: an operator's
security_policy.json updates block (check_command / apply_command)
routes the dashboard's update check, badge, and Update button through the
declared commands, and the gateway then reports no release channel at all.
The check_command runs on every check — the 12-hourly background poll AND
the manual Check button — so it must be side-effect-free and idempotent.
Remote tunnel mode
The desktop app can also connect to a gateway running on a remote host (e.g.
an always-on server) over an SSH tunnel, fetching a fresh token via
ssh <host> kirocrew token on each launch instead of starting a local backend.
See website/electron/README.md and
remote-desktop-setup.md for setup.
See also
- install.md — all three build/run methods and the build targets
- README — project overview and Quick Start