kotgent is a local-first, restart-safe control plane for coding-agent sessions.
Agent processes (Claude, Codex, Junie and plain login shells today) run inside tmux, independent of any user
interface. The IDE terminal, desktop Web UI, and installable mobile PWA are interchangeable clients over one daemon. On a
phone, server-sent Web Push can wake the PWA's service worker when a session needs attention, even after
the app is closed. tmux is the transport and the process-survival mechanism, not the source of
truth: state is derived by replaying an append-only event log, so it survives a daemon restart.
IDE terminal ──────┐
Desktop Web UI ────┼──▶ kotgent daemon ──▶ tmux ──▶ claude | codex | junie | shell
Mobile PWA ────────┘ │ │
│ └──▶ browser push service ──▶ service worker
├── SQLite (event log, session cache, push subscriptions)
└── provider adapter (hooks → canonical events, approvals)
There are two distinct kinds of durability, and kotgent leans on both instead of trying to make tmux
immortal:
- Close the IDE / reload the browser — the agent keeps running in
tmux(a client detached, the process did not die). - Reboot the machine — the process is gone, but the conversation is preserved on disk by the provider
itself (Claude's per-project transcripts, Codex's rollout files, Junie's session directories) and is
restored with
resume.
- Quick start
- Requirements
- Build & test
- The CLI — the task backlog, access & auth, Web UI, sign in from your phone
- Troubleshooting
- The first vertical slice
- Architecture at a glance
- Status & limitations
- Contributing
- License
Install from the Homebrew tap (macOS on Apple Silicon; the formula pulls in tmux):
brew install Heapy/tap/kotgentThe formula installs kotgent itself, not the agent CLIs: claude, codex and/or junie have to be on
your PATH already (see Requirements); the shell kind uses your existing login shell.
Then run the rest from a normal login shell —
kotgent install snapshots that shell's environment (PATH, so the daemon can find the agent binaries,
and LANG, so the TUI renders as UTF-8) into the launchd plist, and launchd would otherwise start the
daemon with a minimal env and no locale at all:
kotgent install # install + boot the daemon as a launchd agent (RunAtLoad + KeepAlive)
kotgent start claude # launch a Claude session inside tmux, in the current directory
kotgent start shell # launch a plain login shell in the same managed terminal surface
kotgent web # open the sign-in form and print its one-time codeThat's the whole loop. From there, kotgent list shows every session and its state, kotgent attach <id>
drops the terminal straight into your shell, and closing either client just detaches — the agent keeps
running in tmux.
Upgrades are brew upgrade kotgent, followed by kotgent install again: the plist records the
binary's real (version-qualified) Cellar path, which a new release invalidates.
To build from source instead, see Build & test.
- macOS on Apple Silicon (arm64). The build targets
macosArm64and links against macOS system libraries; there is no other supported target. - JetBrains Kotlin Toolchain — invoked through the bundled
./kotlinwrapper committed in the repo. You do not need a separate install or Gradle; the wrapper provisions the toolchain (0.11.1) on first run. A JDK is required for the toolchain, for the build-time SQLDelight codegen plugin, and for the JVM-side browser tier (webuitest), whose first run additionally downloads Playwright's browser bundle — see Build & test. tmux— sessions live on a dedicated server socket (tmux -L kotgent), isolated from your normaltmuxand from your~/.tmux.conf: kotgent passes-f /dev/nullon every invocation, so none of your config is loaded into an agent's pane — not your prefix key, bindings, plugins,status-formatordefault-terminal. This is deliberate. A~/.tmux.confis written for a terminal you drive, and one line of it (set -g destroy-unattached on) would kill the agent every time the last viewer detaches. In its place kotgent forces its own small set:destroy-unattached off,default-terminal tmux-256color,mouse on,status off,history-limit 10000,escape-time 10.mouse onis what makes the wheel scroll an agent's transcript — that scrollback lives in the tmux pane, so it is the only way a browser tab that joined an existing session can see anything above the current screen. kotgent arms mouse reporting for every viewer as it joins, but the single upstream uses the last-resizing viewer's geometry; only that viewer has a fully live wheel, and a larger tab's lower/right area may not scroll. Two other things to know: selecting text in the web terminal needs Option-drag on macOS (Shift-drag elsewhere), because a mouse-reporting terminal otherwise sends the drag to the app; and a wheel scroll puts the pane into tmux copy-mode, which every viewer shares — kotgent leaves copy-mode before programmatic Interrupt/REST input. Interrupt returns only after tmux verifies delivery; REST input reports when full PTY write completion was not observed. A PTY error may have written a prefix, so inspect the terminal before resending to avoid duplicated input.focus-eventsstays off: with one tmux client fanned out to many viewers, "is the terminal focused" has no single answer. Developed against tmux 3.7b.claude— the Claude Code CLI, on yourPATH. Session-id preallocation (claude --session-id) needs a recent version; kotgent version-gates it and falls back to aSessionStarthook on older CLIs. Developed against claude 2.1.x.codex— the Codex CLI, on yourPATH, if you want codex sessions. kotgent installs its hooks per launch (codex -c 'hooks={…}'), so your~/.codexis never modified. Codex has no session-id preallocation, so the id is captured afterwards — from theSessionStarthook, or by reading the rollout file Codex writes under~/.codex/sessions. Developed against codex-cli 0.145.junie— the Junie CLI, on yourPATH, if you want junie sessions. kotgent installs its hooks per launch (junie --config-location <kotgent-owned file>), so your~/.junie/config.jsonis never modified. Junie has no session-id preallocation either, so the id is captured afterwards, by reading the session directory Junie writes under~/.junie/sessions. Live state tracking needs Junie's hooks, which are an EAP feature: on a stable build that ignores them the session still launches and attaches, it just shows a coarser state. Developed against junie 26.8.3 (EAP).shell— your current login shell ($SHELL, then the passwd entry, with/bin/zshas the safe fallback). It launches with-l, needs no additional CLI, emits no provider hooks and has no import path./usr/bin/openssland NSURLSession (Web Push only). kotgent lazily uses macOS's system/usr/bin/opensslto generate and sign its VAPID P-256 credential; outbound HTTPS delivery uses the Darwin HTTP client backed by NSURLSession and the system trust store. Both are macOS runtime facilities, not packages to install. If VAPID setup fails, the daemon and in-tab notifications keep working; only server-sent push is unavailable.
./kotlin build # compile the macosArm64 app (+ the sysnative cinterop, + SQLDelight codegen)
./kotlin do kexePath # print the debug app's absolute .kexe path (releaseKexePath for the release one)
./kotlin test # run the test suite./kotlin test runs every tier and the suite has no skips: the native suite (test/), the browser tier
(webuitest/, a real Chromium driven through Playwright), 7 JVM tests for the build-info plugin, the 11
real-PTY checks ptycheck runs (see below) and the 2 self-checks webuicheck runs. The two module tasks
— :kotgent:testMacosArm64Debug and :webuitest:testJvm — are the fast local loops; neither replaces the
aggregate. The counts are deliberately not written here: they move with every change, and the run
itself is the only source of truth that cannot go stale (AGENTS.md carries the current baseline for the
one purpose a number serves — noticing that a change moved it by more than it meant to).
Run build before test, now for two fixture binaries rather than one. ./kotlin test never links a
main binary, and the suite execs two: PtyTest runs ptycheck, while WebUiCheckTest and every browser
test run webuicheck. A missing ptycheck reddens one test; a missing webuicheck reddens the whole
browser tier. Both say so explicitly rather than passing quietly. See
Status & limitations for why they are separate binaries at all.
The first test run downloads browsers, and needs the network for it. Playwright provisions its
browser bundle into ~/Library/Caches/ms-playwright — measured at about 1.1 GB, because
Playwright.create() installs Chromium, the headless shell, ffmpeg, Firefox and WebKit as one set even
though every test here asks for Chromium and nothing else. There is deliberately no npm anywhere in this
repository: the Node driver ships inside the Maven artifact, so there is no package.json, no
node_modules and no npx playwright install step to run. CI caches that directory under a key that
spells the Playwright version out literally, so bumping playwright in gradle/libs.versions.toml means
bumping the key in .github/workflows/ci.yml in the same commit — otherwise every CI run re-downloads a
bundle it can never save.
The produced binary lands under build/ (the macos/app output). Its directory and filename include
the checkout/worktree name, so use ./kotlin do kexePath after build instead of hard-coding either
(releaseKexePath for a -v release build); kotgent below refers to that binary. The command reads
what build left behind rather than triggering it — the toolchain has no way for a plugin task to
depend on the native link — so run it after a successful build, or it fails saying so.
It prints the path and also writes it to build/kexe-path, which is what a script should read: a task
action's stdout reaches you through the build log, so it is prefixed, interleaved with the log's own
lines, and silenced by --log-level before the surrounding noise is. The file follows --build-dir
along with everything else, and a failed lookup deletes it rather than leaving a stale answer behind.
./kotlin build
./kotlin do kexePath
kexe=$(cat build/kexe-path)kotgent <command> [args]
daemon [--port N] run the control-plane server (default port 27508; the launchd entry point)
install | uninstall (un)install the launchd LaunchAgent (io.kotgent.daemon)
start <agent> [cwd] start a session (agent: 'claude' | 'codex' | 'junie' | 'shell'; cwd defaults to the current dir)
[--name N] [--tag T] [--task R]
import <agent> <session-id> register a session started outside kotgent, then resume it
[--cwd D] [--name N] [--tag T] [--no-start]
list | ls list sessions and their states
stop <id> stop a session
resume <id> resume a stopped/crashed/resumable session
interrupt <id> send Ctrl-C to un-stick a session
attach <id> attach a raw terminal to a session
The task backlog (JSON on stdout — written for an agent to parse). Every subcommand takes
[--session S] to name its session explicitly instead of resolving the calling tmux pane.
task add <title> create a task [--body B] [--project P]
task list the project's backlog, in rank order [--project P]
task show [<ref>] one task in full
task next take the next eligible task [--project P] (exit 3: none)
task claim <ref> link this session to a task
task comment [<ref>] -m TEXT add a comment ('-m -' reads the text from stdin)
task review [<ref>] [-m TEXT] move the task to review
task done [<ref>] [-m TEXT] close the task and unlink every session holding it
task unlink [<ref>] drop this session's link; the task's state is untouched
task move <ref> --top | --bottom | --before <ref> | --after <ref>
task dep add|rm <ref> --on R add or remove "<ref> depends on R"
task delete <ref> remove the task, its dependencies and its feed
project list every project the daemon knows
project init [<path>] write .kotgent.json for a project [--name N]
web [--print] open the Web UI in a browser (or print the login URL)
token rotate re-mint the master token (old key stops authenticating)
config get | set public-url <url> read / set the public URL published behind the tunnel
--version | --help
-
daemonbinds127.0.0.1:27508by default (27508=0x6b74= ASCII "kt"). Override the daemon's listen port with--port. The$KOTGENT_PORTenvironment variable does not change the daemon's port — it tells the CLI client (list/start/stop/attach/…) which port to reach a running daemon on. This is the process launchd runs on login. -
install/uninstallwrite~/Library/LaunchAgents/io.kotgent.daemon.plist(RunAtLoad+KeepAlive, so the daemon comes up on login and is restarted if it dies) andlaunchctl bootstrap/bootoutit.installalso snapshots your shell'sPATHandLANGinto the plist: launchd starts the daemon with a minimal env and no locale, so the snapshot is what lets the daemon and the agents it spawns findclaude/codex/junieand render a UTF-8 TUI. Re-run it from a full shell whenever either goes stale. An agent that can't be resolved on the daemon'sPATHfails fast with a clear error pointing atkotgent install, not a silent attach failure. -
startcreates atmuxsessionkt-<id>, launches the requested agent or login shell in it, and records the session. -
importbrings a conversation you started outside kotgent —claude,codexorjunierun in a plain terminal — under kotgent, with its history intact. The import itself only registers the session (notmuxside effects): kotgent verifies the provider's own on-disk record and writes aresumableentry, then immediately resumes it (claude --resume <id>/codex resume <id>/junie --resume --session-id <id>);--no-startskips that and leaves it registered for later. The project directory is discovered from the provider's record; pass--cwdif that discovery fails or picks the wrong directory.shellis deliberately not importable: there is no outside provider session or transcript to adopt. Finding a provider session id:- claude — shown in the
claude --resumesession picker; it is also the transcript's file name,~/.claude/projects/<encoded-project-dir>/<session-id>.jsonl. - codex — shown in the
codex resumesession picker; it is also the trailing UUID of the rollout file name,~/.codex/sessions/<date>/rollout-<timestamp>-<session-id>.jsonl. An archived codex session cannot be imported — archiving puts it out ofcodex resume's reach. - junie — shown in
/history; it is also the directory name under~/.junie/sessions, e.g.session-260730-015553-1j1h. Junie keeps only its most recent sessions' context, so a session whose directory it has pruned cannot be imported. A junie session in which you never submitted a prompt has no recorded project directory, so pass--cwd(there is nothing to resume in it anyway).
Importing an id kotgent already tracks fails with the existing session's id and the right next move (
kotgent resume <id>, or Restore in the Web UI if that session is archived). The Web UI's new-session dialog has a matching Import mode, including the register-only checkbox. One caveat: kotgent cannot detect that the conversation is still live in the original terminal — resuming it there and under kotgent at once runs two CLI copies of the same conversation. - claude — shown in the
-
attachis not a directtmux attach. It is a raw-terminal passthrough over the daemon's terminal WebSocket (tty put in raw mode viatermios, stdin → WS, WS → stdout,SIGWINCH→ resize, terminal restored on exit). Detaching an attach only drops a client; the agent stays alive.
kotgent tracks sessions; the backlog tracks work. Each project gets an ordered, dependency-aware list
of tasks that you groom on the Web UI's /tasks board and an agent inside a session reads from and writes
to. A session's purpose stops being something you remember and becomes something the daemon records — the
sidebar shows which task each session is on, and a task's card shows every session linked to it.
- A project is a committed file, not a path.
.kotgent.jsonat the checkout root holds a uuid and a name, so one backlog survives agit worktree, a move, a rename and a clone. It appears the first time a task is created somewhere that has no project (or when you runkotgent project init), it is written to be committed, and the daemon never commits it for you. Nothing is written until then. - The states are
todo → in_progress → review → done, plus a position you drag on the board and dependencies that mark a taskblockeduntil what it waits on is closed.kotgent task nexthands out the first unblockedtodoin rank order and exits3when there is nothing eligible. - The whole
task/projectfamily prints JSON and only JSON — stdout is the answer, stderr is one{"error":…,"status":…}object. Inside a kotgent pane the commands need no task id:kotgent task show,task comment -m "…"andtask review -m "…"resolve the calling pane's own session. Outside one, pass--session <id>. - kotgent does not enforce one worker per task. You can open a second terminal it never hears about, so
an exclusive claim would be a guarantee it cannot keep; instead a task may be linked from any number of
sessions and the board shows all of them.
task nextwill not hand the same task to two agents in a row, which is the part that actually matters. - The interface an out-of-repo Agent Skill is written against is
docs/agent-task-skill.md.
The daemon binds 127.0.0.1 only. Two distinct keys guard it:
- The master token — the machine key. Stored at
~/.kotgent/token(mode0600, inside a0700 ~/.kotgent; generated from 32 bytes of/dev/urandom). It authenticates the CLI (as aBearerheader), the provider hooks (as their own header), and the issuing of browser tickets.kotgent token rotatere-mints it; the old key stops authenticating new requests immediately (already-open WebSockets survive until they reconnect, since auth is computed once at handshake). - A session cookie — the browser key. A stateless
HttpOnly; SameSite=Strict; Path=/cookie of the formv1.<issuedAt>.<hmac>wherehmac = HMAC-SHA256(master-token, "v1|" + issuedAt). There is no session table — the cookie verifies by recomputing the HMAC, so "sign out every device" is justkotgent token rotate(every HMAC dies at once).
~/.kotgent also holds the generated hook settings, the optional config.json (public URL), and
kotgent.db (including device push subscriptions). Web Push lazily creates the VAPID private key at
~/.kotgent/vapid.pem with mode 0600; deleting or replacing it invalidates existing browser
subscriptions, so notifications must be re-enabled on each device afterwards.
kotgent web # open the sign-in form and print a code to type into it
kotgent web --print # print a credentialed login URL for scripting or copyingNo master token is copied into a URL. kotgent web issues one single-use, 8-character Crockford
Base32 code (40 bits, held in memory for five minutes), opens the bare
http://127.0.0.1:<port>/auth form, and prints the still-valid code. Type it into that browser or an
already-installed PWA. The exchange is protected by a daemon-wide rolling budget of ten failed attempts
per minute in addition to the short lifetime and single-use rule.
kotgent web --print is the non-interactive form: stdout contains exactly
http://127.0.0.1:<port>/auth#ticket=…, while the equivalent grouped code and human hint go to stderr,
so piping the URL remains safe. The fragment and typed code are two representations of the same
credential; spending either invalidates the other. A browser opening the fragment reads it locally,
POSTs it to /auth/exchange, and then uses location.replace("/"), so neither the server's initial
GET /auth nor browser history receives the live fragment.
The UI shows the session list with live state badges and a "Needs attention" queue (fed by the events
WebSocket), and renders a session's terminal with xterm.js over the terminal WebSocket (byte rendering,
keyboard input, resize). Its installable PWA layout adds a mobile sidebar drawer, safe-area handling,
terminal sizing from visualViewport, and a phone-only row for Esc, Tab, Shift-Tab, arrows, Ctrl, and
Ctrl-C. Terminal taps focus the software keyboard without Safari zooming the helper textarea, and a
terminal socket lost — to the app being backgrounded, or to the daemon restarting under it — is reattached
without a reload: on returning to the app, and on the events socket reconnecting, which is the only signal
that a restarted daemon is back. Each attempt checks daemon liveness under a deadline first; a daemon that
is merely unreachable leaves the attempt available for the next one, while a daemon that answers that this
session is gone ends it rather than retrying forever.
The UI is a small router over four paths: / and /s/{id} for the session view, and /tasks and
/tasks/{ref} for the task backlog's kanban board — four columns you drag cards
between on a desktop, one column and a switcher on a phone. Each card shows its blocked marker, its
dependency count and every session linked to it, and every change (a drag, a state move, a link, a
deletion) reaches a second tab over the same events WebSocket without a reload. Sessions carry a badge
linking to their task; the command palette opens the board with ⌘K o and its create form with ⌘K w.
Those bare paths are deep-linkable and installable, which is why the client-facing API lives under
/api/v1.
The sidebar footer identifies the running daemon: local source builds show the release version plus their
embedded short Git hash (for example 0.7.0+81c37fe), while published Homebrew builds show the release
version alone (0.7.0).
A session row also carries an unread pill — how many events have arrived since you last looked at that
session. Looking at it clears it: the browser posts the cursor it has displayed, so the count is
server-side (it clears on the phone and the desktop together, and a second browser sees it clear with no
reload) and persistent (restarting the daemon does not resurrect a cleared badge). Reading a session does
not count as activity, so kotgent list's ordering is unaffected.
The per-device notifications toggle registers /sw.js and the browser's Web Push subscription. A
false → true attention transition sends a payload-less push; the service worker fetches /api/v1/sessions
under a ten-second deadline, shows one notification per waiting, non-archived session, and opens or
focuses that session when tapped. If the fetch fails or stalls it still shows a generic attention
notification. If Web Push is unsupported, denied, or unavailable on the daemon, the live tab falls back
to ordinary in-tab notifications.
There is no TLS on the native build (ktor-server-cio for macosArm64 has no sslConnector — that is a
JVM-only API), so the phone reaches the daemon through a cloudflared named tunnel in front of
Cloudflare Access, not by exposing the port. Point kotgent at the public host:
kotgent config set public-url https://kotgent.example.comThe Web UI's phone button (📱) then issues a code and renders a credential-free QR for
https://kotgent.example.com/auth. Scan it on a phone that has passed Access, choose Add to Home
Screen, launch Kotgent, and type the displayed code into that form. The /auth landing page carries the
PWA install metadata but the QR intentionally does not carry or spend the credential: Safari and an
installed iOS PWA have separate cookie jars, so signing Safari in would not sign in the home-screen app.
An unsigned PWA that launches at / also routes its first /api/v1/sessions 401 to the same form; a later
expired credential leaves the live terminal visible and reports the error instead.
Without a configured public-url the dialog prints the cloudflared ingress snippet to add instead of a
QR. Setting up the tunnel and the Access policy is a one-time host-side step (ingress rule →
http://127.0.0.1:27508, DNS route, a strict Access policy on your own identity — the host fronts a
terminal that can run anything on the Mac).
Authorization is one rule for both surfaces: the Host must be in the allowlist (loopback or the
configured public host), and an Origin, required on any non-GET request and on every WebSocket
handshake and checked for a match whenever it is present, keeps a cookie from being replayed cross-site
(SameSite alone would not — sibling *.example.com hosts are the same site). Hook ingress, ticket
issuance and token rotation are additionally loopback-only: only the browser surface is ever published
outward.
Most real-world breakage traces back to the daemon's launchd environment, which is minimal by design — so the first question is almost always "does the plist still match my shell?".
startfails withagent '…' not found on the daemon's PATH. The daemon'sPATHis a snapshot taken atkotgent install, not your live shell's. Ifclaude/codexmoved (a version manager, a new Homebrew prefix, a freshnvminstall for codex'senv nodeshebang), re-runkotgent installfrom a full login shell. kotgent fails fast here on purpose: the error names the fix instead of leaving a phantomrunningrow.- The TUI renders as a wall of underscores. The tmux client decided it may not emit UTF-8, which
happens when the daemon runs without a UTF-8
LANG— again a stale plist. Re-runkotgent installfrom a shell wherelocalereports a UTF-8 setting. - After
brew upgrade kotgentthe daemon does not come back. The plist records the binary's version-qualified Cellar path, which the upgrade invalidates. Re-runkotgent install. - The port is bound but nothing answers (a rebind fails with
EADDRINUSE, or a client connects and then hangs). Current builds close every spawn path against descriptor inheritance, so this should only come from a long-livedtmuxserver started by an older kotgent, which is still holding the listening socket the daemon that spawned it left behind.tmux -L kotgent kill-serverreleases it — note that this also stops every agent running under that server. - My tmux settings do nothing inside a kotgent pane. Expected: kotgent runs every tmux command with
-f /dev/null, so~/.tmux.confis never loaded on its socket (see Requirements for what it forces instead). Your owntmuxon the default socket is untouched. There is no user-facing override — the option set lives insrc/tmux/TmuxOptions.kt. Note the flag only affects the command that starts a server: if something else already started one on-L kotgent, that server has your config until it is restarted (tmux -L kotgent kill-server, which also stops every agent on it). - I can't select text in the browser terminal / the wheel scrolls tmux instead of my terminal. Both
are
mouse on, which kotgent forces so the wheel reaches the pane's own history (10 000 lines, and the only scrollback a newly attached viewer has). To select text while an agent's TUI is running, hold Option and drag on macOS, or Shift and drag elsewhere. The wheel puts the pane into tmux copy-mode — shared by every viewer of that session — which scrolls back down to the bottom to exit, and kotgent cancels it anyway before sending keys, so Interrupt is never swallowed by it. - Notifications stay in the open tab instead of reaching the phone. On iOS, Web Push requires iOS
16.4 or later and an installed home-screen app; enable it from that app's sidebar so the permission
prompt runs from the tap itself. A missing/unusable
/usr/bin/openssl, denied browser permission, or an unreachable push service disables only server-sent push, and kotgent falls back to live-tab notifications. - Push stopped after
vapid.pemwas deleted, replaced, or regenerated. A browser subscription is bound to the VAPID public key it was created with. Toggle notifications off and on in each installed browser/PWA to register a fresh subscription with the daemon. The key at~/.kotgent/vapid.pemshould remain mode0600. - Inspecting the daemon itself. It is a normal LaunchAgent:
launchctl print gui/$UID/io.kotgent.daemonshows its state, and the plist at~/Library/LaunchAgents/io.kotgent.daemon.plistshows the exactPATHandLANGthat were snapshotted.
kotgent uninstall # bootout + remove the LaunchAgent plist
tmux -L kotgent kill-server # stop every agent still living in tmux
rm -rf ~/.kotgent # token, config/hooks, SQLite data/subscriptions, VAPID private key
brew uninstall kotgent # if installed from the tapkotgent uninstall only removes the launchd entry — the agents in tmux and the state under ~/.kotgent
outlive it by design, so drop them explicitly if you mean to.
kotgent's first milestone is one end-to-end path that proves the core value:
kotgent starta Claude session → close IDEA (Detach) → open the browser → continue the same session → see it flag "needs attention" when Claude asks for approval.
Concretely:
kotgent start claudelaunches Claude insidetmuxsessionkt-<id>and records it.- Attaching from the IDE terminal and then closing it (Detach) drops one WebSocket subscriber. The
daemon holds the single upstream
tmux attachclient and fans it out, so the agent keeps running with no client attached. - Running
kotgent webopens the credential-free sign-in form and prints a one-time code; after signing in, clicking the session re-attaches to the very same live process — the browser is just another client of the same fan-out. - When Claude hits a permission prompt, its
Notificationhook posts to the daemon, which normalizes it into anApprovalRequestedevent; the reducer moves the session toneeds_approval, and the events WebSocket lights the session up in the browser's "Needs attention" queue.
State is event-sourced. Adapters normalize provider signals into a canonical AgentEvent; a pure
reducer folds the append-only log into a Projection (the derived state). Restart-safety is just
replay. The code is split into a host-free core and thin edges:
| Layer | What it does |
|---|---|
core/ |
Host-free domain: AgentEvent, SessionState, SessionMeta, Reducer, Projection. No I/O. |
store/ |
EventStore interface + SQLDelight-backed SqliteEventStore (single-writer, WAL, append+cache in one transaction); TaskStore + SqliteTaskStore beside it, which never writes the sessions table. |
task/ |
Host-free backlog domain: the tracker seam, ordering, the dependency graph, and .kotgent.json project resolution (pure filesystem — no git subprocess). No I/O beyond an injected filesystem. |
pty/ |
TerminalBridge + Broadcaster — the lazy single-upstream tmux attach fan-out. |
tmux/ |
Thin wrapper over tmux -f /dev/null -L kotgent via a popen-based ProcessRunner: one argv builder that isolates the server from ~/.tmux.conf, plus the small option set kotgent forces in its place. |
adapter/ |
AgentAdapter contract + the Claude, Codex and Junie adapters (launch/resume spec, hook config, event normalization). |
daemon/ |
Session manager, start-up reconciliation, provider-id capture, stop modes, and TaskService — the two stores called sequentially, never nested. |
push/ |
Attention-edge tracking, SQLite subscription store, VAPID key/JWT signing, Darwin/NSURLSession delivery, and notifier lifecycle. |
transport/ |
Ktor CIO server: control REST, events WS, terminal WS, Bearer/cookie auth (authorize), /auth exchange, push/auth/control/hook routes, static PWA. Every client-facing route lives under /api/v1; the hook ingresses and the whole /auth bootstrap surface deliberately do not. |
cli/ |
Subcommands + the raw attach passthrough, and the JSON-only task/project family (which resolves its own tmux pane through /whoami). |
launchd/ |
plist generation + install/uninstall. |
sysnative/ (module) |
Owns all raw POSIX/cinterop bindings (PTY via openpty+posix_spawn, tty raw, executable-path). |
ptycheck/ (module) |
Test fixture, not a product: a main binary running the real-PTY checks a test binary cannot link (KT-78062), driven from the suite by PtyTest. |
fakes/ (module) |
The shared test doubles (FakeTmux, FakeEventStore, FakeTaskStore, FakeProjectFs, MemoryProjectFileWriter) — a module because the root test fragment and the webuicheck main binary both consume them. |
webuicheck/ (module) |
The browser tier's fixture: a main binary (KT-78062 again — it serves a real PTY) that assembles the real server over those doubles, replaces the writing edges with in-memory ones, and takes scenario commands on stdin. |
webuitest/ (module) |
The browser tier itself: JVM tests only, driving a real Chromium through Playwright against the pages webuicheck serves. |
plugins/sqldelight-gen/ (build plugin) |
Runs SQLDelight codegen from sqldelight/*.sq at build time. |
The authenticated push HTTP surface is GET /api/v1/push/vapid-key, POST /api/v1/push/subscribe, and
POST /api/v1/push/unsubscribe. The GET returns the VAPID application-server key; the POSTs persist or remove
the browser endpoint and its keys. Both POST routes inherit the transport's required, same-origin
Origin check. They are not loopback-only, because a PWA must register through the configured public
host.
For deeper conventions and the toolchain gotchas, see CLAUDE.md.
This is the first vertical slice — deliberately narrow but genuinely end-to-end. Be honest about what is and isn't here:
In the slice (v1):
- Four launch kinds: Claude, Codex, Junie and Shell. The three providers run as a TUI in
tmuxand report through hooks; Shell runs the user's login shell through the same lifecycle and terminal fan-out. Codex and Junie both fire a realPermissionRequest, soneeds_approvalis precise there rather than inferred from a generic notification — kotgent only observes it, the operator answers in the terminal. Junie's hooks are an EAP feature: without them a junie session still launches and attaches, its state is simply coarser. - Two keys, browser-friendly auth. The daemon still binds
127.0.0.1only, but browsers authenticate with a stateless, no-secret-in-URL session cookie (kotgent webmints a one-time ticket), and a phone can sign in through a cloudflared tunnel + Cloudflare Access. The CLI and hooks keep using the master token;kotgent token rotateinvalidates every cookie at once. - The full
start → Detach → browser → continue → needs-attentionpath, session reconciliation on daemon restart (running/stopped/crashed/resumableclassification), provider-id capture, and launchd install. - Session metadata & lifecycle polish. Each session shows its agent CLI version and, best-effort, the model it is running; Done stops an agent and archives it off the sidebar (restorable, history kept); and an opt-in, per-device notification toggle registers server-sent Web Push for attention edges, with live-tab notification fallback.
- Installable mobile PWA. The manifest, root service worker, home-screen icons, responsive drawer, visual-viewport terminal sizing, software-keyboard focus handling, special-key toolbar, foreground terminal reattachment, and notification deep links are all shipped. The service worker is network-only: there is deliberately no offline shell when the local daemon cannot serve useful state.
- Import of externally started sessions.
kotgent import(and the Web UI's Import mode) registers a conversation begun in a plain terminal and continues it under kotgent — fan-out, push, and mobile access included, with the provider's own on-disk record as the history (see The CLI). - A browser end-to-end tier. Web UI behaviour is executed rather than described:
webuitestdrives a real Chromium through Playwright againstwebuicheck, a fixture binary that assembles the real daemon over the shared doubles infakesand serves a terminal from a real PTY running a deterministic script instead of a provider. Each test spawns its own harness on an ephemeral port, signs in through the real login form, and leaves nothing behind outside the checkout.
Backlog (not built yet):
- The Codex app-server (JSON-RPC v2) as an alternative event source — structured items, two-way approvals, and no terminal. That is a different product surface (a chat UI, not a terminal fan-out), so it is deliberately separate from the adapter above.
- A fourth provider:
cursor-cli— another TUI-in-tmuxadapter behind the same shape (launch spec + hook config + normalizer, an ingress route, aVendorStoreProbe, and anagentFactoryOfentry), with nothing incore/, the store, or the fan-out changing. Open questions to resolve first: whether it exposes per-launch hooks (like Codex's-c 'hooks={…}') or forces a user-scoped config, how it reports approvals, and whether/how a session id can be preallocated or must be scanned after the fact. - Structured mobile actions such as native approve/deny buttons outside the agent's terminal. Approvals remain interactive TUI operations today.
- A diff viewer and snapshots.
- Usage-limit tracking — how much of each provider's quota is left and when it resets (Claude: the 5-hour window and the weekly cap; Codex: the weekly cap).
- A browser-independent JavaScript test layer. The pure modules (routing, data merges, command matching, retry classification) are proven one level up in the browser tier or not at all; adding a runner for them must not add a build step.
Why some checks live in their own binary. A Kotlin Toolchain issue
(KT-78062) means our own raw-cinterop path cannot be
called from a test binary at all — partial linkage turns every such call into a stub that throws
IrLinkageError, and nothing in the YAML works around it. Main binaries do link the cinterop, so the
affected assertions live in the ptycheck module, whose main() runs all 11 for real:
- a
catround-trip through the pty, resize(TIOCSWINSZ) succeeds,- the child's exit code is captured,
- spawning a nonexistent command throws,
- the spawned child inherits only its tty (the
POSIX_SPAWN_CLOEXEC_DEFAULTguarantee — an inherited listening socket would keep the port bound after the daemon dies), prepareCloseunblocks a full master write,closestops the reader before releasing the master descriptor (a freed fd number can be reused by another session while a stale reader still runs),- concurrent
closeruns teardown exactly once, tmux attachruns on the spawned pts,- a resize reaches a running
tmux attach(the child gets no controlling terminal, soPty.resizemust deliverSIGWINCHitself — see CLAUDE.md), TerminalBridgefans out over that real attach.
The suite runs them through PtyTest, which executes that binary and asserts it exits 0 — so these are
real, non-skipped tests. webuicheck is the second fixture binary, for the same reason: it serves the
browser tier a terminal from a real PTY, which no test binary can open, so it too is a main() — and it
carries its own --self-check mode with the 2 checks that need that cinterop directly, driven from the
suite by WebUiCheckTest exactly as PtyTest drives ptycheck. That precedent reaches as far as
KT-78062 does and no further: everything a browser can observe is a named assertion in webuitest, not
another entry in a SUMMARY total=N.
Everything around the cinterop is still tested directly via interface fakes
(FakePtyHandle, FakeTty). Third-party klibs that happen to contain cinterop (Ktor, the SQLite
native-driver) and the stock platform.posix bindings are not affected — they link into test
binaries normally — so the transport, store, and tmux layers are fully tested in CI. The full root-cause
write-up is in CLAUDE.md.
Issues and pull requests are welcome. A few things worth knowing before you open one:
- The build is the JetBrains Kotlin Toolchain, not Gradle. Use the committed
./kotlinwrapper; there is nobuild.gradle. Dependencies and module wiring live inmodule.yaml/project.yaml. - Keep
./kotlin buildand./kotlin testgreen, and runbuildbeforetest(see Build & test). New tests are expected to come with the change; the suite has no skips and should stay that way. - Web UI changes go through three tiers, and which tier a claim belongs to is decided by whether a
running page could answer it.
test/transport/WebUiServingTest.ktkeeps what only an address can prove — URLs, media types, caching headers, content revisions, path safety — plus the registry every newly served ES module must be added to. Anything a Chromium can answer belongs inwebuitest/, as executed behaviour against the real server; it is no longer true that browser behaviour is verified by hand. Changed modules must still passnode --check <file>— this stays a no-build Preact app — and what remains manual is only what desktop automation cannot faithfully reproduce: installed-PWA lifecycle, safe areas, software-keyboard geometry, touch physics and notification prompts. The full strategy is docs/TESTING.md. - Read CLAUDE.md first if you are touching the build, native code, or the event model. It
documents the invariants (host-free core, single-upstream
tmuxfan-out, the event-sourcing rules) and the toolchain gotchas that are expensive to rediscover. - The target is
macosArm64only. CI runs on Apple-silicon macOS runners withtmuxinstalled.
Licensed under the Apache License, Version 2.0.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work shall be licensed as above, without any additional terms or conditions.
