Releases: CVERInc/clikae
Release list
clikae 0.27.1
A strict correctness/security audit pass. Every fix ships with a regression test
proven to go red on the pre-fix code (except the two paths the suite tests
manually — the watch tail loop and --ephemeral stash race — verified by a
standalone harness instead).
Security
-
Ephemeral/burn lock files moved out of world-writable
/tmpinto the private
$HOME/.clikae/state(0700). Their names are predictable
(ck-ephem-<run_id>/ck-ephem-slot-<cksum>), so in/tmpanother local user
could plant one as a symlink (ourexec 8>/9>would truncate the target) or
as a plain file thatclikae clean's GC reads as a dead lock — killing your
tmux sessionck-<name>andrm -f-ing your$HOME/.clikae/state/<name>.*. A
private dir removes the ability to plant, and sidesteps macOS's/tmppurge.
The GC now scans only the private dir and skips any malformed session id.
(DESIGN-tmux Rule 6 updated.) -
The tmux launch command no longer double-expands engine passthrough args.
The pane command was built asbash -c "$target_cmd", and since tmux runs it
viash -c, the outer quotes let the shell re-expand a passthrough arg carrying
$, a backtick, or a quote — and a backtick /$(…)was executed. It is now
single-quoted through a helper, soclikae claude x -- --foo '$(cmd)'reaches
the engine verbatim.
Fixed
-
Auto-carry after a mid-session limit created a session literally named
ck-._switch_supervise's same-engine relay used$tank_id, which was
local to a different function (run in a subshell) and thus empty — so the
carried session, its scrollback file, andCLIKAE_TANK_NAMEwere all wrong. -
clikae burn's reported exit code was always 0. The tmux wrapper's EXIT
trap read$?off a… | teepipeline with nopipefail, so therc=…in the
"real task failure" line reported tee's status, not the engine's. -
fleet_mcp_prelaunchrewrote a tank's.claude.jsonon every single
launch. Its no-op check byte-compared jq's reformatted output against the
on-disk file (jq reindents and drops the trailing newline), so it never matched
and the file's inode was replaced each time — racing any live session on the
same tank. The no-op is now decided semantically in jq. -
clikae renameorphaned a tank's burn-order entry and dry marker. Both key
the tank by name from outside its directory, so a rename silently dropped the
tank to the bottom of the board order and stranded its red-badge record. Now
carried across (both the env-adapter and agy rename paths). -
clikae rename/migrate/memoryno longer detach a symlinked dotfile.
Rewrites usedmv "$tmp" "$file", replacing a~/.zshrc(orAGENTS.md)
symlinked into a dotfiles repo with a detached 0600 regular file. They now write
through the file, preserving its inode, mode, and symlink. -
The home board's solo toggle now matches
clikae solo. Theskey only
flipped the marker file, leaving a shared tank in the "solo BUT STILL SHARING"
stateclikae memory statuscalls impossible; them→ isolate menu item
still called the retiredmemory isolate(a hard error). Both now delegate
to the realclikae soloverb, which also leaves/rejoins the Soul group. -
clikae to/relayrefuse a solo tank as an explicit target. grammar
§127 says a solo tank is never ato/relay target; auto-carry andmemory sharealready honored it, but a named target slipped through. Solo tanks are
also dropped from relay's target picker. -
memory shareno longerrm -rfs a prior own-memory stash. The$PWD-slot
path destroyed an existing.clikae-soul-stashbefore stashing, against the
"reversible, never lost" contract; it now uses a unique suffix like its siblings. -
The auto-resume nudge reaches the engine window, not the waiter's own pane.
wake_sittyped "go" into-t <session>(the current window) — which is the
wakecountdown window whenever the user was watching it, so the engine never
resumed. It now targets the first non-wakewindow explicitly. -
clikae watch's prompts no longer read their answer off the transcript. The
dry-detection loop pipedtail -finto the loop's stdin, so everyconfirm()
inside it (the wake opt-in, the "switch now?" / auto-consent) read the next
transcript line instead of the keyboard, and theexec clikae handoffhanded
the tail pipe to the started engine. The tail now reads on fd 3. -
The update-check tag from GitHub is sanitized to version characters before
it is printed or cached, so a tampered release name can't smuggle an escape
sequence to the terminal or a control byte into the cache.
clikae v0.27.0 — the agent stops parsing prose
burn --json and conduct --json
AGENTS.md's first non-negotiable rule is judge by the artifact/output, never the exit code — and clikae made an agent read that judgement out of sentences.
burn is the worst case. With rerouting, the tank that did the work is often not the one you named, and the only record of which was a line of prose.
clikae burn codex work --prompt-file t.md --artifact out.md --json \
| jq -r 'select(.ok) | "\(.engine)/\(.tank) wrote \(.artifact_bytes)B"'burn: {ok, engine, tank, artifact, artifact_bytes, reason, reset,
rerouted_from[], elapsed_s, run_id}
conduct: {out_dir, captured, dry, other,
legs:[{engine, tank, status, detail, output, output_bytes}]}
One object on stdout, every word of progress on stderr. artifact_bytes is the artifact's own measurement, so the evidence rule 1 asks for travels with the verdict rather than being a second call the caller has to remember.
reason separates the two failures that read alike in prose and are not the same thing: every reachable tank is dry — wait, or add fuel — from no fresh artifact and no limit, where the task itself failed. conduct's status separates EMPTY from DRY for the same reason: clikae never judges, so the caller is the one who has to rank the legs.
Audited the whole surface for this: 33 commands, 5 had --json. These two were the gap on the axis AGENTS.md actually cares about.
The suite could not safely run beside a copy of itself — and the hooks guarantee it does
clean's live guard runs ps -axo command= so it can never offer a session a process still has open. Correct for the command; fatal for concurrency. Suite A's clikae processes appear in suite B's snapshot, the fixtures use fixed session ids, and B decides those sessions are live and skips the rows it is asserting on.
pre-commit runs the suite and so does pre-push, so git commit && git push overlaps them by construction.
Reproduced by starting a second run 25 s into the first:
round 1 A=0 B=0 notok_A=0 notok_B=0
round 2 A=1 B=1 notok_A=4 notok_B=2 <- both red
round 3 A=1 B=1 notok_A=2 notok_B=1
round 4 A=1 notok_B=1
Every failure was [ "$status" -eq 0 ] on a clikae clean. Three of four rounds.
This is the explanation for a pre-push red that ~218 isolated runs could not reproduce — 10 full suites, 5 sequential and 3 concurrent copies of the file, and 200 runs of the exact file at the exact commit in a worktree, all green. The condition every one of them was missing was another suite running beside it.
scripts/test.sh now takes $TMPDIR/clikae-test-suite.lock and waits, saying what it is waiting for. A suite that is red for a reason outside the code teaches you to ignore red, which is the one thing a gate cannot afford.
(lockf -k, not lockf — without -k two processes both get rc=0, the same trap the ephemeral slot lock hit in 0.25.0.)
clikae v0.26.2 — a tag whose own suite passes
No product code changed since v0.26.1 — that release is where the narrow-terminal fixes are, and it is worth reading.
This exists because v0.26.1 was tagged on a tree whose own suite fails on a clean macOS. pty-smoke size, added in 0.26.1, needs tmux; GitHub's macos-latest runner does not have it; and the check had no skip state. So CI went red for a missing tool rather than a defect — and a red that means "a tool is absent" is exactly how a red that means "something is broken" stops being read. Anyone installing from source and running scripts/test.sh would have seen a failure that was not one.
Three states, not two: a check a run could not perform is skip, never a pass and never a failure. verify-tmux-birth.sh and verify-agy-shapes.sh both carry that rule in their own headers; pty-smoke.py was the file that did not have it, and it is the one that broke.
Proven both ways: with tmux on PATH the two size checks run and pass; with PATH=/usr/bin:/bin they report skip and the suite exits 0.
clikae v0.26.1 — the board did not fit, and the diagnosis was right
The board did not fit, and the reporter's diagnosis was right
Reported from a PineNote over ssh: opening clikae does not fit in the
terminal, and it looks like the layout is hardcoded rather than fluid.
Measured on the repo: it overflowed at every width below 72 columns.
| 69 cols | more clikae status · clikae doctor · clikae demo · clikae help |
| 45 cols | the tank rows — 4 lead + dot + 3 spaces + 7 + 8 + 22, all literals |
| 38 cols | the interactive frame's autonomy legend |
| 34 cols | the wordmark + summary header |
There is a fluid layer — _home_cols, _home_row_budget,
_home_wrap_prefixed, _home_trunc — and a set of rows that bypassed it.
The more row is the one you see first: a bare printf of a hardcoded string,
not even a call to _home_cols, and the last line of the board.
Three causes, not one
Literal column widths, written out twice. The tank row's 7 / 8 / 22 lived
at both tank-row sites — the static board and the interactive one — and neither
asked the terminal's width. They share one _home_tank_fields now: the account
column is what is left after the fixed chrome (capped at the old 22, so a wide
terminal is unchanged), truncated to it rather than only padded to it, and
padded only when something follows. Otherwise the padding is trailing
whitespace that still counts as width — which is how a row whose account was
the single character - measured 45 columns.
An escape hatch that produced the overflow it prevented. When the hanging
indent left under 12 columns to wrap into, _home_wrap_prefixed widened the
budget to the whole terminal — and still printed the prefix. Every line came
out exactly hang columns too wide. At 30 columns with a 19-column prefix it
wrapped text to 29 and printed 48.
Every tmux session was born 80x24, whatever terminal you were on.
tmux new-session -d is detached, and a detached session has no client to take
its size from, so tmux used default-size. Measured on a pty at 60, 100 and
140 columns: 80x24 every time. The engine paints its first frame for 80 columns
and only afterwards do we attach and tmux resizes — so the first screen you see
was laid out for a terminal you are not using, and that applies to the
engine's own TUI as much as to our board.
And it never repainted when you resized
tui_read_key blocks — its argument is a file descriptor, not a timeout — so
the loop sat there until a key arrived, while every layout figure was already
being read per draw. The board was always capable of reflowing; nothing asked
it to.
A trap … WINCH does not fix that. Bash installs handlers with SA_RESTART,
so the blocked read resumes and the flag the trap set is never looked at —
measured on a pty, SIGWINCH after the first frame produced zero bytes of
repaint. The wait polls once a second instead, and repaints only when the size
actually changed.
🔴 And it cannot branch on the read's exit code. macOS's stock bash 3.2 —
the shell clikae runs on — returns 1 for a read -t timeout, where bash 4+
returns >128. Two consecutive one-second timeouts both came back 1,
indistinguishable from EOF. So the loop asks something independent: a terminal
that is gone has no size, and that is the only case that should quit.
Gated on the path that actually breaks
tests/bats/board-width.bats renders the whole board at ten widths and measures
every line — on both paths. clikae with no tty draws the static board,
so a gate that only ran the binary would have missed the interactive frame the
reporter was looking at. The existing width test called _home_wrap_prefixed
directly and proved the helper wraps, which says nothing about the 35 printf
sites that never call it. The new gate caught four defects while the fix was
being written.
pty-smoke.py size and pty-smoke.py resize cover what needs a controlling
terminal — in bats they would pass by not looking. Before the fix: 80x24 at
every width, and nothing drawn after a resize.
Checked, and NOT a bug
Attaching a smaller client to a larger existing session. window-size latest
resizes correctly, 140x40 → 60x29. The first probe said otherwise — it used
tmux attach &, which never attaches at all, because a background process
group cannot own the tty. list-clients was empty and the reading was worthless.
clikae v0.26.0 — the gates were never asked to fail
The last two releases audited the docs. This one audits the audit.
v0.24.0 and v0.25.0 were both the same finding: a document that names a thing is
what an auditor reads instead of the code, so a stale one hides the gap it
would otherwise expose. The answer was gates — a suite, a doc contract, a CI
run. This release asks the next question, which is the uncomfortable one:
has any of it ever been seen to fail?
A green suite says the code behaves on the inputs someone thought to write. It
does not say a guard exists.
A test that never ran the function it named
run bash -c 'wake_ask_once claude work < /dev/null'bash -c forks, and shell functions do not cross a fork. Measured: inside that
subshell both wake_ask_once and the confirm() stub defined two lines above
report NOT-VISIBLE.
So what the test asserted was that a command not found message does not
contain the word ASKED. True however wake_ask_once behaves — including with
its TTY check deleted, i.e. asking on every headless launch and then typing into
a live session, which is the exact harm the test names in its own comment. It
passed for two months.
Now called in the shell that holds the stub, and proven to fire: remove
[ -t 0 ] && [ -t 1 ] || return 0 and it goes red with ASKED in the output. The
other 25 bash -c sites in the suite were swept for the same shape; they are
all real subprocesses.
scripts/mutate.sh
The only evidence a guard is load-bearing is watching the suite go red when you
take it away. So, four mutations against docs/memory.md §4 — the promises
clikae makes about the human's data:
| break this | tests that caught it |
|---|---|
| share without ever opting in | memory share: fans a tank's memory… |
| make a solo tank stop being solo | 🔴 a SOLO tank never joins… |
| silence the cross-account note | carry_notice_once prints the note… |
| turn seed-by-copy into a move | memory share: … (seeded by copy) |
4 guard(s) proven, 0 hollow, 0 mutation(s) that never applied
Not wired into scripts/test.sh — it copies the repo per mutation and costs
minutes. It is the ruler for a question you ask deliberately, and it is checked
in because a reading with no ruler behind it is an anecdote.
That third column exists because of its own first run, which reported three
hollow guards. All three were the ruler: tank_is_solo lives in
profile_store.sh and the mutation edited tank.sh; notice.sh's function is
carry_notice_once, not the name that was guessed. Nothing was mutated, and the
tests were green for the most boring reason available. A mutation that did not
apply is indistinguishable from a guard that works, so every row now checksums
its target and reports ⛔ rather than a verdict.
(The reason those expressions silently did nothing: perl needs balanced braces
inside s{…}{…}, and a shell function's replacement text almost always carries
an unmatched {. Delimiter is ! now, with the reason written down.)
The doc gate had the disease it was built to cure
Two holes, both of which left it green on exactly the drift it exists to catch.
Its scope was a list written from memory. Candidate names were extracted with
a hand-written prefix list — clikae_|tmux_|soul_|wake_|…. Measured: 20 real
functions are named in the docs and were invisible to it, including
tank_is_solo, next_tank, history_log, load_adapter and five limit_*.
Rename one in every source file, leave the docs alone: gate stays green. A gate
whose scope is an enumeration is silent on precisely the entries its author
forgot — and forgetting is the failure it was built for. Now unioned with "any
backticked all-lowercase token containing an underscore", which needs no list.
It read the working directory as the source. A sed -i.bak during that very
experiment left lib/commands/*.sh.bak on disk, and the gate counted them as
repo source in both directions at once. The renamed function still "existed",
because the backup held its old definition. And the backup counted as a caller,
so the docstring was asked to list burn.sh.bak. An editor swapfile or a merge
.orig does the same. It reads git ls-files now.
Reading the real file list turned up two more things. docs/proposals/ is out of
scope with a reason — a proposal names the function it is asking for, and that
function does not exist yet; that is what a proposal is. And the first draft used
mapfile, which macOS's stock bash 3.2 does not have: the shell this gate
actually runs on.
clikae memory status --json
clikae memory status --json | jq -r '.[] | select(.dispatchable) | .tank'So an agent can ask which tanks it may use instead of parsing prose.
dispatchable is false for a solo tank, and false for the impossible
solo-and-shared state — there the wiring does not match the label, so nothing
about that tank is safe to reason about.
Four entries were filed under the wrong release
conduct's read-only enforcement, burn's scoped write grant, conduct legs no
longer leaving transcripts, and the Rule 8 correction were all written into the
[0.25.0] section after the v0.25.0 tag. Anyone running 0.25.0 — which is
what Homebrew serves — would have read that changelog and believed their
conduct legs cannot write. They can.
Moved to where they actually ship, labelled with when they were written. It is
the same defect the last two releases have been auditing out of the docs,
committed in the file that describes the audit.
scripts/test.sh: 765 passing.
clikae v0.25.0 — a document that names a thing is read instead of the code
Everything here is one shape
v0.24.0 gave the tmux layer the owner its design doc had specified and nobody
wrote. This release is what that owner made visible — and the pattern turned out
to be bigger than tmux.
A document that names a thing is what an auditor reads instead of the code.
When it goes stale it does not merely fail to help. It hides the gap it would
otherwise expose, because the reader now believes the thing exists.
Three defects, found by asking the same question three ways:
| the doc said | the code had |
|---|---|
clikae_spawn_session holds Rules 1 and 2 |
no such function, for two years |
fleet_mcp_prelaunch is called from switch and run |
relay had it too; burn had none |
| "window-size latest 下最近使用的 client 決定尺寸" | nothing set the option |
So there is now a gate for it: scripts/doc-names-exist.sh runs with the tests
and fails when a doc names a function that does not exist. Exemptions need a
written reason. Prose drifts and cannot be checked; a name can.
clikae resume was starting sessions differently from everything else
It called adapter_run directly — the one user-facing command that launched an
engine with no tmux: no wake watcher, no scrollback capture, no roaming. The
board's own resume has always routed through switch, so the same intention
produced two different sessions depending only on how you typed it.
Drift, and the dates say so: _resume_exec is from June, the tmux layer landed
in August touching switch.sh and burn.sh. resume.sh was missed.
It then survived the v0.24.0 audit, which is the part worth keeping. That audit
asked who calls tmux — four sites, all converged. resume.sh could never
appear on that list. Searching for callers finds drift among the sites that
already opted in; it cannot find the site that never did.
The question that finds it is who launches an engine, and it has a definite
answer: one entry per adapter_run call site. Asked that way, five paths, four
already correct — and burn missing its Soul and fleet-MCP links, so a headless
run in a fresh directory executed with no memory nobody had asked to remove.
--ephemeral is the sub-agent primitive, and now it says so
Measured rather than claimed:
transcripts before / after 308 / 308 nothing in `clikae resume`
Soul memory files before/after 436 / 436 the real brain untouched
three parallel cold reads all rc=0 no residue
cd "$(mktemp -d)" && clikae claude <tank> --ephemeral -- -p "<question>" is a
cold, unattended reader that spends that tank's quota and leaves nothing behind.
AGENTS.md documents the shape now, because none of it was written anywhere.
One sharp edge is gone. The memory slot is keyed on $PWD, so two ephemeral runs
in one directory fought over it — and the second did not merely fail: its
self-heal read the first run's symlink as a crashed leftover and moved the real
memory back out from under a live engine. Now one lock per slot, refusing the
second with an explanation instead of a bare ln: error.
(lockf -k, not lockf. Without -k two processes both get rc=0 on the same
file — a lock that never locks, which is a guard silent on every input.)
Two things that had never worked, and one you could not escape
The waiter could never exit. Its only exit condition was tmux has-session
— and the watcher is a window in that session, so it is the reason the session
is alive. The condition could never become true. When the engine's window closed
and the waiter was the last one left, there was no way out but closing the
terminal.
The board announced a countdown that did not exist. The live row packed
three values into one field joined by spaces, and one of them is "2m ago" —
which has a space in it. So read attached age wake put "ago" into wake, and a
non-empty wake means "a waiter is counting". Every selected live row claimed
one. The render site's own comment forbids exactly that. The unit was right and
tested; the seam around it was not.
K on the board closes a running session. The Live section could see a
session and name it and offered only "enter it". It asks first, and the question
carries the fact that makes it safe: the conversation is a transcript, so
clikae resume brings it back. What ends is the process, not the work.
Honest scope
The scrollback replay has never worked on Linux. On ubuntu tmux 3.4 the capture
file is never created; on macOS it is. Ten CI cycles ruled out the pane being
torn down (probes survive), sequencing (an EXIT trap did not run either), timing,
and -t target resolution — that last one was a real bug and is fixed. The
cause is still unknown.
So the test skips off macOS and points at DESIGN-tmux Rule 2b, which records the
ruled-out list so the next person does not rediscover it. Skipped rather than
deleted: the feature works where it works, the gap is written down, and a skip
carrying a reason invites the fix. Contributions very welcome.
Also fixed on the way: a server born by clikae burn took tmux's 2000-line
scrollback instead of 50000; burn published the caller's whole environment into
the tmux process argv, where ps shows it to every process on the machine; the
global options accumulated one copy per session; and the test suite's own
tmux kill-server could reach the maintainer's live tanks.
scripts/test.sh: 755 passing.
clikae v0.24.0 — the design doc specified a function that was never written
The design doc specified a function. Nobody wrote it.
docs/DESIGN-tmux.md has been the single source of truth for this layer since
v0.4 — six rules, each with receipts. Rule 2 asks for one shared set of exits.
Rule 5 refers to a wrapper called clikae_spawn_session as "Rule 1 與 Rule 2 的
封裝函式".
That function appeared three times in the design doc and zero times in the
source. So four call sites — switch.sh ×3, burn.sh ×1 — each
re-implemented the rules by hand, and drifted exactly the way Rule 2 predicted.
Every fix below is that one omission.
lib/core/tmux.sh is now the only tmux new-session in the codebase.
What was silently wrong
A server born by clikae burn carried none of clikae's global options.
burn.sh used a bare tmux new-session -d, without the 200-character option
prefix the other three sites copied between themselves. Measured on a server
created that way:
$ tmux show-options -gv history-limit
2000 # tmux's default. The intended value is 50000.
It went unnoticed because the next clikae switch repaired it. The window is
only visible while the burn's own server is still alive — exit-empty means
asking a dead server for its options quietly starts a fresh one that answers
with the defaults, which reads identically to the bug whether or not the bug is
there.
clikae burn published your environment to ps. It passed all of
compgen -e to tmux new-session as -e KEY=VAL pairs, and those pairs stay in
the tmux process's argv — the server's argv, when the burn is what created it,
for as long as that server lives. Verified on a server born two days earlier: its
command line still listed every pair it was created with. The environment now
travels in burn's wrapper script, created and chmod 0600'd before anything is
written to it.
A carried session could be handed an SSH agent socket that was never created.
The dry-tank carry path passed clikae's stable symlink path without the
ln -sf that creates it. The interactive path did both. Now one function does.
Selecting and copying text works again
Reported as "since clikae started using tmux I cannot copy text" — and the
diagnosis is that the text was never unselectable. It was unreachable, and
clikae put it out of reach.
Rule 1's *:smcup@:rmcup@ disables the outer terminal's alternate screen,
which the scrollback capture needs. The cost was never counted: the outer
terminal's scrollback then fills with tmux's full-screen redraws, so the wheel
scrolls debris while the clean 50000-line history sits in tmux where the wheel
cannot reach it. And tmux's default set-clipboard external forwards an
application's own OSC 52 but never emits one for tmux's own selections, so even
entering copy-mode and yanking put the text in a buffer only tmux could paste.
mouse on puts the wheel and the drag onto tmux's real history;
set-clipboard on puts a copy-mode yank on the system clipboard. The cost,
stated rather than hidden: a native terminal selection — for pasting somewhere
tmux is not — now needs the option key held.
Found while measuring it: those options are appended, and the option block
ran on every session creation rather than only at server birth. A two-day-old
server carried four identical *:smcup@:rmcup@ entries and four
xterm*:extkeys. Harmless to tmux, and the same shape as everything else here —
an operation written as though it were idempotent when it is really cumulative.
Rule 7: a server keeps what it was born with
A Soul kept under ~/Library/Mobile Documents became unreadable to every tank on
one tmux server and stayed readable on another. The only symptom was EPERM,
with no prompt and nothing in any log.
tests/bats/roam.bats had already written half of this down —
everything else is inherited from the SERVER's process environment — which is
whoever started the server, not us
— and fixed it for environment variables by passing them explicitly with -e.
The other half cannot be fixed that way: on macOS the server also inherits its
file-access identity, and nothing can hand that over after birth.
So soul_prelaunch now probes the memory it is about to hand over. The test is a
two-syscall asymmetry rather than an errno:
| measured | meaning |
|---|---|
stat succeeds, read fails |
the path is right and still yields nothing |
| the permission bits allow the read, and it fails anyway | something above the filesystem refused — the server, not a chmod |
| the bits deny it | an ordinary permissions problem, reported as one |
[ -r ] calls access(2), which reads only the bits and answers "yes" in the
first case. Only an actual read tells the truth.
It warns and starts anyway. A session with no memory is bad; a tank that will not
start is worse.
And tmux_spawn_session now records what the server was born from
(tmux show-environment -g CLIKAE_SERVER_BORN), because by the time it matters
the parent is always launchd: the server at the centre of this could not be
traced to either the interactive or the unattended path, since both leave
byte-identical command lines.
Running the test suite no longer kills your live tanks
tests/bats/roam.bats runs a bare tmux kill-server twice — it needs a
known-empty server to prove create-or-attach — and the suite had no tmux
isolation of any kind. On the default socket that reaches every clikae tank the
person running the tests has open. scripts/test.sh was unsafe to run on any
machine that dogfoods clikae, which is every machine that runs it.
Two parts, because the obvious one is not enough:
TMUX_TMPDIR=<iso> tmux list-sessions -> the isolated server
TMUX=<real> TMUX_TMPDIR=<iso> tmux list-sessions -> the four live tanks
An inherited $TMUX overrides TMUX_TMPDIR, and anyone running the tests from
a tmux pane — the normal way — has it set. The suite now unsets TMUX and
TMUX_PANE as well, and keeps a negative control proving the unset is
load-bearing rather than decorative. tests/tools/pty-smoke.py needed the same
treatment for a different reason: it never invokes tmux, but it drives clikae on
a real pty, so clikae really does create sessions.
Verifying it
scripts/verify-tmux-birth.sh covers what bats cannot reach — properties of the
server hosting a session, on a real machine. Read-only and idempotent. Its first
check is whether the installed clikae is even the one with the tmux layer,
because every later check would otherwise measure the old build and pass for the
wrong reason.
It reports three states, and so does the probe it tests: a check it could not
perform is skip, never a pass. The class of bug being guarded against
throughout this release is a green light that means "I did not look".
scripts/test.sh: 753 passing, 0 failing.
clikae v0.23.0 — the waiter worked, nothing ever started it
The waiter worked. Nothing ever started it.
v0.18.0 shipped a session that waits out its usage limit and picks itself back up.
Last night a real limit arrived and nothing happened.
The evidence, all four parts of it: the tank went dry at 21:57 with "resets 12am
(Asia/Tokyo)"; the phrase parsed correctly to midnight; the preference was still
unset, meaning nothing had ever asked; and no wake window had ever existed.
Detection lived in two places and the limit walked past both — clikae watch,
which nobody runs in order to be interrupted later, and the supervised launch,
which only fires once the engine has exited. Sitting in a live session that
hits its limit is the ordinary case and the only one that matters at 3am, and it
reached neither. The release notes had promised something that, on the path
people actually take, could not happen.
So the session watches itself. Every session clikae starts now carries a
wake window that asks the same question the board asks — is this tank dry? —
once a minute, and hands over to the countdown in place.
One window, two phases. Bare wake while watching, wake 13h38m once
counting down. A session gains one extra window, not two, and the "only one
waiter per session" guard already covers both phases because it matches
^wake( |$). No daemon, no state file, no model of anybody's quota: it asks a
question that reads the transcript and self-clears, and it dies with the session.
The question moved to launch
Asking when the limit arrives was the original design, and it cannot work: the
question would be posed by a watcher in a window nobody is looking at. That is a
deadlock, not consent — and it is exactly why the preference was still unset
last night.
At launch a human is demonstrably present: they just typed the command. The
friction is still paid exactly once. Where there is nobody to ask — a pipe, CI, a
headless run — it stays silent and schedules nothing, which is the safe direction
for a feature that types into a live session.
Also fixed
Shift+Enter inserts a newline again. tmux defaults to extended-keys off,
which flattens a modifier onto the key it modifies before the application sees
it — so Shift+Enter arrived as a plain Enter and an engine that treats Enter as
"send" submitted instead of adding a line. Adding tmux had put a translator in the
middle of the keyboard.
Two settings, because they answer different questions: whether tmux forwards
the extended encoding to the application, and whether it asks the outer
terminal for those sequences at all. Without the second there is nothing to
forward.
Only a NEW client picks this up — terminal features are resolved when a client
attaches, so a session you are already inside keeps the old behaviour until you
detach and come back.
Five assertions that could not fail
Every defect found while building this was the same species, and none of them was
in the feature:
run wait "$pid"runs in a bats subshell, where the background job is not
a child — sowaitalways failed, and an assertion of "non-zero" was satisfied
no matter what the code did. Two tests had been passing that way.- A watcher test asserted on a window named
wake <time>in a setup that never
creates a wake window at all. Forcing the watcher to ignore dryness entirely
left it green. It asserts on what reaches the pane now. local rc=1; wait || rc=$?reports failure when the wait succeeds.- roam.bats' long-running intermittency was never timing. It passed the stub
engine's log path through an exported variable, and tmux passes only its
update-environmentlist into a session — everything else is inherited from the
server's process environment, which belongs to whoever started the server.
So the variable arrived only when that test happened to start the server itself.
The engine ran, the roaming assertions passed, and the count of engine starts
was zero because nothing could be written. - Both test harnesses had to learn to answer the new question: a pty-driven test
is a terminal, so every launch test sat at the prompt and reported the
engine's output as missing.
brew upgrade clikae · CHANGELOG
clikae v0.22.0 — the board knows what is running
The board knows what is running
Type clikae and the top of the page now lists the sessions alive on this
machine — and Enter attaches to one, rather than starting anything.
▸ Live
● work claude "auth redirect — next: retry the callback test"
● x codex "Transcreate the escape guides to 7 locales"
▸ Tanks
…
This came from someone ssh'ing into their Mac, running clikae, and finding the
session they had left running nowhere on the page. The board could say which
accounts they had and what they did yesterday. What is alive was a category
tmux created, and the board never grew it.
v0.21.0 made that sharper rather than better: keying a session on the arguments
it was started with is correct, but it means the Resume row opens a second
conversation. Without a Live section there was no way back into the first one
except remembering the tank's name.
The third column is the session's title, not a status word. claude/x does
not tell you which piece of work that is — and "I need to know the name I gave
it" was the whole reason to build the section this way.
Selecting a row adds one line beneath it. For a tank that has hit its limit, that
line is the vendor's own sentence, verbatim — and clikae's promise on the line
after, only when a waiter is genuinely attached:
❯ x claude "Transcreate the escape guides"
You've hit your session limit · resets 3:50am (Asia/Tokyo)
-> resuming in 13h38m
resets is their fact. resumes is ours.
Only this machine, and it says so
tmux is local, so running clikae on a tablet lists the tablet's sessions. That
is stated in the docs rather than papered over: it is the truth about where a
session lives, and seeing it makes the mental model right. To reach a session on
another machine, log in first, then run clikae there — ssh yourmac 'clikae'
hands the command a pipe for output, so clikae correctly takes its no-tmux path
and the section is absent.
No tmux installed means no section at all, not an empty heading.
The tank is resolved against the disk, not parsed
ck-claude-my-tank-123 is either tank my-tank with an argv digest, or tank
my-tank-123. Both are legal names, so the parse is genuinely ambiguous — and
guessing would be the kind of decision that looks right until someone names a
tank with a number in it.
Asking the filesystem which reading exists settles it, and buys something else
for free: a session whose tank has since been removed drops off the board instead
of drawing a row that cannot be opened.
Two notes from the tests, because both were nearly the wrong conclusion
The obvious fixture proved nothing. For "somebody else's tmux session is none
of clikae's business", a session called notclikae-1 is rejected by the
tank-exists check whether or not the ck- gate is there — so both guards could
have been deleted and the suite stayed green. The specimen is adversarial now,
one character from being ours. That turned up something worth writing down: the
two ck- gates are redundant on purpose — widening either alone leaves the
suite green, widening both turns exactly that test red. Recorded in the file,
because "nothing fails when I delete it" is the usual reason a redundant guard
gets deleted.
Adding the new test file turned two older tmux tests red, while they passed
3/3 on their own. A controlled run with the file moved aside confirmed the
trigger was the new file — and the cause was theirs: fixed-second waits. One was
waiting for "nothing in tmux is attached", which is never true on a machine
with a session open, so it burned its whole timeout every run and, under a loaded
suite, outlived the stub engine it was measuring. The session died, the next
attach made a new one, and an assertion about roaming failed for a reason that
had nothing to do with roaming. Both wait on real conditions now.
brew upgrade clikae · CHANGELOG
clikae v0.21.0 — two tabs, two screens
Two tabs, two screens
Reported and reproduced the same day: open clikae claude work, then from the
board resume a different past session on that tank — and both tabs showed the
same thing.
The tmux session was named after the tank alone, so the second launch found one
already running and attached to it. The --resume <sid> went nowhere, because
nothing was started to receive it. Before the tmux layer existed, that command
would have started a second engine; this was a regression it introduced.
Measured before touching anything, with a stub engine on two ptys:
before STARTED args=[] <- one engine
ck-codex-repro: SCREEN args=[] <- one screen, twice
after STARTED args=[]
STARTED args=[resume SESSION-TWO] <- two engines
ck-codex-repro: SCREEN args=[]
ck-codex-repro-3723925944: SCREEN args=[resume SESSION-TWO]
A session is now keyed on what was asked for. A bare clikae <engine> <tank>
means "take me to my tank" and keeps the stable name — walking away and coming
back has to land in the same place. Anything after -- means "run the engine
with THESE arguments", which a session started with different ones cannot
satisfy, so a short digest of that argv is appended.
Identical requests still collide on purpose: resuming the same session id twice
returns you to that conversation, which is the answer you wanted.
A second waiter, found by Linux
clikae wake promises one waiter per session. The guard matched the window name
exactly — and the waiter renames its own window to carry the countdown
(wake 13h38m). Seconds after it started, the guard stopped recognising it, so a
second limit would have attached a second waiter, and two of them would have
typed go into the same pane, minutes apart, into a conversation that had
already resumed.
CI on Linux won a race macOS had been losing quietly. The honest reading of that
red was not "this test is flaky on Linux" — it was "this test is watching the
right promise, and the promise is broken." The new test starts from the renamed
state instead of racing to it, so it fails on any machine.
The CLI surface is checked against the family's design system
signet is CVER's design system for
plain-text terminal output. clikae was the one tool never wired to it — its own
SPEC says so — so the board could drift and nothing would go red. CI runs its
linter on every push now.
The first honest run reported 25 violations across 12 files. An earlier run of
mine reported zero, and that was a broken invocation rather than a clean repo:
violations go to stderr and a pipeline ate them. Worth saying out loud, because a
lint that reports nothing looks exactly like a tidy codebase.
Twenty are fixed here, in two groups:
- Thirteen printed a status glyph next to a badge that already said the same
thing —log_done " ✔ …"prints[ DONE ]and then a tick. One state, two
vocabularies, which is what a closed badge set exists to stop. Two of them were
added the day before by the same hand that ran the lint. - Seven were a marker inside help text, now words. One of those was first
reworded wrongly —Never:in front of a sentence that is a caveat, not a
prohibition — and a test asserting on that sentence caught it.
Five are kept, and named in the check rather than hidden. They are the
selection cursor ❯. signet's roles table decides a [x] / [ ] checkbox
("is this one chosen?") and has no cursor ("where are you?"), and by its own
first ruler those are different jobs — a row can be under the cursor without
being chosen. The linter flags it only because U+276F falls inside the emoji
block it scans; nobody decided the mark was wrong. Reported upstream, and not
changed unilaterally: the SPEC itself says "Change the look, never the keys."
The exception matches the cursor itself, not a file and line, so it cannot
silently widen — a second glyph riding along on the same line still fails. Proven
in three directions before being trusted: a new emoji anywhere goes red, a second
glyph on a cursor line goes red, a clean tree stays green.
The ref is pinned, never main. Following a neighbour's HEAD would hand them
the power to turn this repo's CI red. Bumping the pin is how clikae says which
version it conforms to, which makes drift news instead of a surprise. The wrapper
also runs the linter's own self-test before trusting a clean verdict — it arrives
over the network, so "did it stop checking" is a real question.
brew upgrade clikae · CHANGELOG