A field guide to clikae behaviours that look like bugs but are deliberate — usually because a vendor's real nature leaks through clikae's uniform "tank" model. If something here surprised you, it's working as intended; the why is below. (For things that are actually broken, see the CHANGELOG / issues.)
Fuel gauge & limits
The coloured dot on the board isn't "which tank I'm on." It's a fuel gauge:
🔴 dry · 🟡 weekly-% (BETA) · 🟢 ready · ○ no reading. "Which am I on" is the cursor
❯ and the burn-order position. (See
DESIGN-board-fuel-dots.md.)
There is no "you are here" marker on a row. The board deliberately doesn't draw
one: with many tanks open at once it is noise, and the shell you happen to have run
clikae from is rarely the one you care about. The active tank is still computed —
it drives the launch hint and the relay source — just not drawn.
A codex tank can read ○ even though codex is fine. ○ means "no reading",
not "no fuel" — clikae only colours a dot it can actually justify. codex does
record a usage limit to disk (its interactive rollout carries a
codex_error_info: usage_limit_exceeded field, which is what clikae matches — the
English sentence beside it is vendor copy and will drift), so a limited tank does
turn 🔴 and clears itself on the next successful reply. But a tank nobody has
touched inside the scan window has nothing to read, and clikae says so rather than
guessing green.
This page said the opposite until 2026-07-27 — "exec-stdout-only, never written to a file clikae can scan". That belief was load-bearing: it is why the fuel dot and
clikae autowere claude-only for months. Nobody had opened a codex rollout after a real limit.
A codex reset time can read odd (e.g. 2026-06-05 07:00) and carries a
· seen HH:MM tag. codex reports its reset in UTC, for whichever limit window
the headless run hit (a 5-hour roll, not necessarily the weekly cap its own TUI
shows). clikae shows the vendor's words verbatim (it never computes a time), so the
· seen HH:MM tag states when we observed it — read it as a snapshot, not a live
countdown. claude is exempt (its dry is re-read live and already absolute + timezoned).
An idle Claude tank reads expired, not a percentage — and it does not
need a new login. Claude's access token lasts a few hours and only a running
session refreshes it, so a tank nobody has used since yesterday answers the usage
endpoint with a 401 even though its login is fine and its quota is whatever it was.
clikae says exactly that (clikae usage --json: source:"expired",
reason:"expired-token") instead of the flat unknown it used to print, which
looked identical to a tank with no login at all — and a dispatcher that reads
"unknown" as "no data, go ahead" can burn straight into a 99%-weekly wall. It does
not refresh the token itself: that would mean speaking the vendor's OAuth
refresh protocol from clikae, which is not documented. Run a session on the tank,
or clikae usage --wake <tank> (one trivial headless prompt through clikae burn,
so the cockpit is refused without --force-cockpit), and the next read shows
numbers again — the expired reading is only cached for 60 seconds so that read is
fresh. "Expired" requires a refresh token in the credentials; a 401 without one
reads unknown/no-credentials, because that tank really does need a login.
Two tanks on the same account both go red at once. A usage limit is
account-level, not tank-level. So if claude/L and claude/MFC share one login,
hitting the limit on one marks both dry (and the reserve skips the sibling — no point
hopping onto the same exhausted quota).
The yellow (weekly-%) dot may never appear. It's BETA — it relays Claude's own "used N% of your weekly limit" notice if Claude serialises it where clikae can read it, which isn't yet confirmed. Yellow staying dark is the safe default.
Carrying a session (to / relay / watch / auto)
clikae to <a-specific-tank> doesn't check whether that tank has fuel. Only the
bare clikae to (no target) uses the fuel/account-aware reserve. When you name
a destination, clikae takes it as your explicit call and carries you straight there —
same contract as burn --to / relay <from> <to>.
clikae to carries "the session you were just in here" — keyed to your current
directory. With no live $CLAUDE_CONFIG_DIR (the bare switch / alias / .app never
export it), clikae finds the session by the current directory's most-recent transcript.
Run it from a different directory and it resolves to that directory's session, not
the one you remember. Pin a shell explicitly with eval "$(clikae env <engine> <tank>)".
clikae to codex <other-codex-tank> starts a FRESH session, not a resume. clikae
can only truly carry a live session for engines that implement the carry hook
(adapter_relay) — today that's claude. codex stores sessions in a way that isn't
copy-resumable across tanks, so to/relay say so and start clean. (clikae to
announces "FRESH (not a resume)" for these.)
clikae to codex -y (or --fresh) can hard-error. Those flags are relay
(same-engine carry) options; if the target turns out to be a different engine, it's
a handoff (a cold brief), so the flag doesn't apply and clikae refuses rather than
silently ignore it.
clikae watch --auto's consent is global and permanent. Granting it once
authorises auto-switching for every future --auto watch on any tank, until you
delete $CLIKAE_HOME/auto-relay-consent. (clikae tells you the file + how to revoke.)
clikae auto safe/full only affects sessions launched through clikae
(BETA). A session you opened via an alias / .app / a bare claude isn't
supervised, so auto has no effect on it. Engine coverage is claude and codex —
both persist their limit where clikae can read it after the session exits. agy is
out for a structural reason, not a missing feature: one global login, so there is
no per-tank signal to read. grok is out for a different reason: it reports a limit
only on the exit path (a stderr sentence plus exit status 1 — unlike codex,
which exits 0), and nothing lands in the session files clikae scans, so there is no
after-the-fact reading to take.
Antigravity (agy)
clikae agy <tank> changes ALL your shells, not just this one. agy hardcodes
~/.gemini and ignores env vars, so clikae switches it by repointing a machine-wide
symlink (and moving the Google login between Keychain slots). Unlike the per-shell
clikae claude/codex <tank>, this is global — clikae status and the board both label
it so. Reversible with clikae agy --release.
clikae burn codex refuses an --artifact outside its writable roots.
Under workspace-write, codex writes only under its cwd (the first --add-dir,
default the artifact's parent) and /tmp; extra --add-dir values are
read-only to it, and burn says so once on stderr. An artifact elsewhere could
never be written, so burn stops before starting instead of timing out on
EPERM. If a run still ends on a sandbox Operation not permitted, the
reason is sandbox refused the write; the engine's output usually names the
fallback path it wrote to instead.
clikae burn agy <tank> runs one tank at a time, never in parallel. It does
work (since v0.10.0 — the Keychain carry made a tank switch non-interactive, so burn
can hop agy onto the next tank when one runs dry). But agy has ONE global login, so
the hop moves that global tank the way clikae agy <tank> always has, and two agy
tanks can never run at once. That's structural, not a missing feature. For a
one-shot on the account that's already active, clikae agy <tank> -- -p "…" is the
shorter path. (See agy-dispatch.md.)
clikae agy <tank> with no terminal switches and stops. The interactive UI
needs a real TTY, so in a script or a piped context clikae completes the switch,
says so, and returns 0 rather than exec'ing a TUI that can only fail with
could not open TTY. That makes clikae agy <tank> usable as "just switch".
Pass a headless prompt (-- -p "…") and it always runs.
clikae burn agy --artifact <path> produces a file agy never touched. agy's
headless mode can't write to your paths, so clikae captures its stdout into the
artifact and labels the row accordingly. See
agy-dispatch.md — including the limit: a large answer may
arrive as the pointer agy printed rather than the content it buffered.
agy appears in clikae adapters with a subcommand strategy and no env var.
That row is a resume-only capability shim, not a switchable engine: agy is
architecturally a target, and clikae_is_target — not "an adapter file exists" —
is what every classification path reads. Which is also why the PowerShell adapter
table mirrors 14 of the 15 adapter files: subcommand ones aren't switchable
engines. clikae tanks footnotes agy's global-login nature.
Grok
A grok tank always reads ○ on the fuel gauge. Not a missing feature — there
is nothing on disk to read. grok surfaces a usage limit only as it exits (a
sentence on stderr and exit status 1; codex, by contrast, exits 0), and writes
no limit marker into summary.json, the session logs, or anywhere else under
GROK_HOME — checked after a real limited run. Its own /usage (alias /cost)
answers "how much is left, when does it reset", but as a live query inside a
running session, and clikae's fuel gauge reads state a session left behind
rather than calling a vendor API. So grok stays ○ rather than guessing green;
for the reset time, ask grok with /usage.
Each grok tank needs its own grok login. GROK_HOME re-homes grok's whole
state directory, and auth.json lives inside it — so a fresh tank is genuinely
signed out (it says "Not signed in" rather than borrowing ~/.grok's session).
That is the isolation working, not a lost login.
GROK_HOME moves grok's state, not the grok binary. The official installer
puts the binary at ~/.grok/bin/grok and that path on your PATH. Tanks live
elsewhere; keep ~/.grok/bin on PATH or no tank can launch.
A read-only conduct leg on grok can still write to /tmp. --sandbox read-only is kernel-enforced and refuses to touch your project (grok logs an
FsViolation when it tries), but its documented shape keeps GROK_HOME and the
temp directories writable so the session can persist itself. "Read-only" means
your files are safe, not nothing anywhere was written. clikae adds a second,
in-process fence — a --tools allowlist — for the platforms where the kernel
profile can't be applied.
clikae mcp share doesn't reach grok tanks. clikae's fleet MCP list is merged
into a JSON mcpServers object; grok keeps its servers in config.toml as a TOML
[mcp_servers] table. Rather than write a shape it might corrupt, clikae leaves
grok out — use grok mcp inside the tank.
clikae hooks share is claude-only, and --ephemeral does not drop a shared
hook. Fleet hooks are merged into a tank's own settings.json, which only the
claude adapter declares (adapter_hooks_config_file). And unlike the fleet's MCP
servers — which an ephemeral run leaves behind by passing the engine's own
--strict-mcp-config — there is no per-run flag that turns hooks off, so a cold
reader still runs whatever the fleet shares. Keep a shared hook to things that are
safe on every run; a hook that must not run in a cold reader belongs in one tank's
settings.json, not the fleet's.
A grok session started by burn shows (no preview) on the board. grok fills
generated_title when it titles a conversation, which a one-shot headless run
never gets to. The row is real and resumable — it just has no name yet.
A grok session's title is whatever /rename last set. grok stores the
model-written title and a manual rename in the same generated_title field
(session_summary keeps the original machine title). So a renamed session shows
your name on the board — and there is no way to show the machine title again.
Engines on one board
claude's subagent transcripts are not listed anywhere as sessions. A
Claude Code subagent's log is written beside its parent session's, in the same
project directory, as agent-<id>.jsonl. It is not a conversation you can
reopen — claude itself refuses (not a UUID and does not match any session title) — and on a working store there are as many of them as there are real
sessions, each "titled" with whatever brief its parent dispatched. So they are
left out of the board's Continue list, the clikae resume picker, prefix
resolution, and the "N sessions total" / "N more in this store" counts. Two
deliberate exceptions: clikae resume agent-<id> with a full id still
finds the tank and the directory (paste one and it works), and clikae clean
still offers them, because they are disk like any other file and reclaiming
disk is what that command is for.
codex and grok "Continue" rows show no recap (just an age), unlike claude. claude writes AI-titles + recap lines into its transcript; codex writes neither, and grok writes a title but no recap — so those rows gracefully degrade to title + "N ago". Nothing is missing — there's just less to show.
A moved/renamed working directory can hide a codex or grok session. Both record
the session's cwd and clikae matches on it (claude slugs $PWD instead). Move the
dir and the recorded cwd no longer equals $PWD, so the session goes invisible to
relay/handoff/board even though it exists. Run from the original directory.
(grok also names its session folder after the encoded cwd, but clikae deliberately
reads the recorded value instead — a folder-name scheme is the vendor's to change.)
agy's board "Resume" rows fall back to the whole tank when this directory
has none of its own. Every engine's rows are scoped to $PWD, agy's
included: claude slugs $PWD, codex/grok/agy match the session's recorded
cwd/workspace. But on real agy installs workspace is a constant — every
indexed conversation records your home directory, never the project directory
you ran it in — so a strict $PWD filter would leave the agy rows empty
everywhere but $HOME (#34). So when nothing in the tank names this
directory, agy's rows are every session in every agy tank instead — not
only the active one; the board's Resume rows have never been per-tank for any
engine — newest first. The fallback is all-or-nothing on purpose: one agy
session recorded in this directory means you get that one, not that one plus
the whole tank, because a row meaning something different from the row above
it is what made the list unreadable in the first place.
Those fallback rows always rank below every row that does belong to this
directory, however new they are. The board ranks one list across all
engines, so without that rule a tank full of recent agy conversations simply
filled the board and the one claude session recorded in the directory you are
standing in fell off the bottom — the very thing the scoping is for, arriving
through the fallback. A courtesy row never costs a real one its place. The $PWD check is
bounded to the newest CLIKAE_AGY_CWD_SCAN_MAX (default 50) candidates per
tank, so a tank whose recent conversations all belong elsewhere costs a fixed
number of reads and then takes the fallback. Either way the list is capped
board-wide the same way every engine's is (CLIKAE_HOME_RECENT_MAX, default
10), the heading names the directory it is showing, and a dim line says how
many more sessions the store holds (clikae resume lists every directory).
Sessions a clikae burn lane started stay hidden (clikae resume --all, or CLIKAE_RESUME_ALL=1, shows them), and they are filtered out
before the board cuts the list to CLIKAE_HOME_RECENT_MAX, so a tank full of
fresh lane one-shots pushes real sessions down the list rather than off it.
That promise has exactly one bound, and the board states it rather than hiding
it: each tank is asked for CLIKAE_HOME_RECENT_MAX plus its own recorded
burn sessions, capped at CLIKAE_HOME_RECENT_SCAN_MAX — which defaults to the
burn sidecar's own cap (CLIKAE_BURN_SIDECAR_CAP, 2000), so only a sidecar
larger than clikae clean's GC allows can reach it. If it is ever reached and
the list still comes up short, the Resume block says so ("N sessions hidden as
burn runs · list truncated") and points you at clikae resume --all; it never
draws a short list that reads as a complete one. That note is worked out inside
the board process and never written to disk, so a board that is killed
mid-render leaves nothing behind. The record of which sessions were burns lives
in state/burn-sessions/<engine>/<tank>, keyed by engine id for every engine
(agy's is antigravity/). A store an older clikae wrote under agy/ is moved
there by the next command you run (merged line by line if both exist).
The Resume rows are sessions a person opened — nothing an engine ran on its
own (#153). agy and codex subagent conversations, claude -p / SDK runs and
codex exec runs are left off the board, whoever started them. What decides is what
the engine itself recorded about the session, never what its first message
says: for agy, the conversation's own metadata names a parent conversation or
carries an agent configuration (the self subagent and built-in agents such
as research alike); for claude, the transcript's entrypoint (cli is the
terminal UI, sdk-cli is print/SDK mode); for codex, the rollout's
thread_source first (a thread the terminal UI's own multi-agent feature
spawned says subagent, and carries its parent's originator, so it is asked
before that), then its originator (codex-tui versus codex_exec). A human's agy conversation and
a clikae burn's look the same in agy's metadata, so agy burns are hidden the
way every engine's are, by the burn record above. A session whose kind cannot
be read at all — no metadata, an older transcript that never recorded it — is
still listed, with its title cut shorter. Like burns, these are skipped
before the list is cut, so the board asks each tank for up to
CLIKAE_HOME_MODE_SCAN (default 100) more candidates than it shows; a tank
with more than that many subagents newer than its newest human conversation
can still come up short. clikae resume is unchanged and still lists them.
Every title on the board is one line — newlines, tabs and control characters
become spaces — and at most CLIKAE_HOME_TITLE_MAX (120) columns
(CLIKAE_HOME_TITLE_UNKNOWN_MAX, 60, for a session whose kind is unknown)
before the row is drawn and cut again to fit the terminal.
--ephemeral only works on claude. It needs an engine whose long-term-memory
layout clikae knows how to stash to a throwaway; today that's claude. codex and grok
join the Soul through a pointer note instead of a real memory dir, so there is
nothing to stash. Other engines report a clean "not supported" rather than pretend.
A handoff brief never decodes a literal \uXXXX JSON escape. clikae handoff
unescapes \n, \t, \", \\ (grep/sed/awk only — no jq/python) on every engine,
claude included; a \uXXXX sequence (rare — JSON only escapes to it for control
characters, or when a writer forces plain-ASCII output) prints unchanged rather than
decoded. Was true before #33 fixed the bigger gap (codex's and grok's own transcript
shapes matching nothing at all — see the CHANGELOG); it's a documented follow-up on
every engine now, not a regression.
An INTERACTIVE --ephemeral run still writes a transcript into the tank. It
drops your memory, your skills and the fleet's MCP servers — so the session does
not know you — but Claude Code only honours --no-session-persistence together
with --print, so leaving no trace at all is available in the headless shape
(clikae claude <tank> --ephemeral -- -p "…") and not interactively. The wording
on screen says which one you got, deliberately: incognito means it doesn't know
you, not it never happened.
Headless tasks (burn) — the left-behind scan
A file whose name contains a newline is reported as two paths, one of which
does not exist. The scan's per-file stat batch is newline-delimited because
sort -rn needs lines, so such a name splits across two reads: the first half is
listed as a path that isn't there and the second half is dropped. A
NUL-delimited pipeline would fix it and is not proven against BSD stat/sort,
so this is a known limit rather than an unverified cross-platform rewrite.
Repository names containing newlines are unaffected; only the per-file list is.
dirty and files count different things on purpose. dirty is git's own
status count for that repository, and from a parent repository's point of view
a nested repository's whole working tree is one untracked entry — so a nested
repo adds 1 to its parent's dirty while its files appear only on its own row.
files is attributed to the innermost repository that owns them, because that is
the repository whose push would carry them.
The scan's watchdog closes fds 3 and 4, not every fd it inherits. Those two
are the ones burn itself is known to open (the run's tee, and --json's real
stdout), and closing them is what stops a failed clikae burn --json | jq from
sitting open past process exit. Any other inherited descriptor — a caller's own
fd 5, the collision lock's fd 9 — is still inherited. With a real process group
this is moot, because the watchdog's sleep dies with its group; on the
single-pid fallback (a platform that will not give the bounded child a process
group of its own) the pre-fix shape remains — and on that fallback a grandchild
a bounded call forked can outlive the bound. --json's
left_behind_kill_mode (pgroup / single-pid) says which shape a given run
got, so this is a fact you can read rather than one you have to infer.
Ambient GitHub watching (watch github) — run directories
A run directory with no status.json is deleted after 7 days. Every poll
that finds at least one new event writes
$HOME/.clikae/logs/watch-github-<org>-<epoch>/status.json — the file
clikae wait resolves. A directory without one is a poll that created its
directory and then died before writing any terminal state: nothing will ever
add the missing file, and clikae wait can never resolve it. Both sweeps used
to skip exactly those and keep them forever. They are now removed once their
mtime is older than CLIKAE_BURN_LOG_RETENTION_DAYS (default 7, the same knob
as burn's own log retention; 0 disables the sweep here too).
Two things are deliberately exempt from that delete, because the directory
name alone cannot tell them from a crashed run: one that still holds an
events.jsonl, and one whose name after watch-github- is an org this host
watches. An org's durable log lives at watch-github-<org>/events.jsonl,
never has a status.json, and has a directory mtime that does not move when
the log is appended to — and for an org literally called foo-2024, its name
is also a perfectly legal run-directory name for org foo. Deleting one would
lose that org's entire event history; keeping a rare half-written run directory
costs a few kilobytes. So a crashed run directory that got as far as writing
events.jsonl is kept, on purpose, rather than risking the other mistake.
Run-directory rotation is per org, and orgs sharing a name prefix are
disjoint. Rotating org foo (newest 200 kept) only ever considers
directories named watch-github-foo-<digits> or
watch-github-foo-<digits>-<digits>. It used to select with a bare
watch-github-foo-* glob, which also matched every run directory of org
foo-bar — so two orgs shared one 200-directory budget and the older mtimes
lost, whoever they belonged to (#111).
Management verbs
clikae migrate makes claude ask you to log in again. claude stores its OAuth
token in the macOS Keychain, keyed by a hash of the config-dir path — not inside
the dir. Move the dir and the hash changes, so the token no longer matches. Use
clikae migrate --keep-login to copy the Keychain item across.
The "in-use" guard on rename/migrate/remove is best-effort. It scans live
processes for a tank in use right now; it can't catch a check-then-open race, and
the TUI-vs-daemon classification is a command-string heuristic. It errs toward warning,
not silent damage.
clikae <name> refuses when the name exists in two engines. A tank's name is its
identity, but if work exists under both claude and codex, clikae can't guess which —
it lists both and asks you to qualify (clikae claude work).
Two screens on one tank
With two terminals attached to the same tank at once, the window is the size of
the one that attached MOST RECENTLY — not of the smaller one. clikae sets
window-size latest deliberately (lib/core/tmux.sh): roaming means the screen
follows whoever just sat down. The cost is that while both are attached, the
other terminal is looking at a window wider or taller than itself and loses the
columns that do not fit. Nothing is wrong and nothing needs resetting — detach
the one you walked away from and the window snaps to the one you are at, within
a fraction of a second. (Measured on tmux 3.4: 0.29s with one client; indefinite
while a larger client is still attached, and immediate the moment it leaves.)
A tank exists, and its engine is already running, before anything is attached
to it. clikae <engine> <tank> creates the tmux session detached and
already sized from your terminal (new-session -d -x … -y …, so the engine's
first frame is painted for the terminal you are actually on), and only then
attaches. So there is a short window — longer on a loaded machine — in which
tmux ls shows the session, the engine has started, the window is already the
right width, and #{session_attached} is 0. That is a launch in progress, not
a session nobody is watching. Anything that wants to know whether you are
looking at it has to ask for the client (tmux list-clients -t <session>), not
for the session (#101).
Touch terminals over ssh
Copying from a phone is not something clikae has measured. The touch bindings (usage.md) were measured against a-Shell on iPhone for one thing only: what a swipe and a tap send on the wire, and what tmux does with them. Two halves of "copy text on a phone" are still unverified (#108):
- a-Shell's own long-press selection. It selects what the terminal is
drawing, and it was measured (2026-09-16) to work whether or not tmux's mouse
mode is on — which is why
@clikae_touch_dragcan giveMouseDrag1Paneto scrolling on a phone without taking selection away. How it behaves while tmux is in copy-mode, or across a pane boundary, is still unchecked. - OSC 52 reaching the iOS clipboard. clikae sets
set-clipboard on, so a copy-mode yank is emitted as OSC 52 — but whether a-Shell honours it, and whether a selection spanning several pages survives, has not been measured on a device. The server-side half is all that is claimed.
Tap-to-page (@clikae_touch_pages, off by default) was measured the same way
the scroll half was: against a real tmux server, asserting tmux's own
#{scroll_position}. It has not been run on a physical iPhone — the
gesture reaching tmux as a press/release pair is inherited from #88's
measurement, not re-measured for this feature. That inheritance turned out to
be safe for a tap and wrong for a flick: the 2026-09-16 device measurement
found a flick sends motion events and no MouseUp1Pane at all, which is what
@clikae_touch_drag exists for. Read it as the standing warning it is —
"inherited from an earlier measurement" is not the same claim as "measured".
Drag-to-scroll (@clikae_touch_drag, on by default since 2026-09-25) is the one touch feature
whose input was measured on a physical iPhone. Its output was not: every
assertion about what then happens — the history scrolling, the wheel bytes
reaching an alternate-screen application, off still selecting text — comes
from a real tmux 3.4 server on Linux, three of them from a real client driven
with synthetic SGR bytes. On 2026-09-25 the finished feature was watched under
a thumb for the first time (iPhone, a-Shell → ssh → tmux 3.7b): with it off, a
flick was drag-selection and nothing scrolled; with it on, the screen scrolled.
That observation is why it is now on by default. It was a person looking, not
a recorded measurement of distance or speed.