kari v0.12.0
kari v0.12.0 — built from 4a49d37.
This release lets a card hold work for later, and carry more of it.
A card now takes a screenshot or a file, and every run of that card reads it.
A card can be booked to start when the rate limit resets, so you do not come
back at the reset to press Start. A card can be added and edited while its
node sleeps, and the write lands when the node answers.
Two changes make an unattended run safer to leave alone. An autopilot run can
open a pull request, but it cannot merge, tag or publish. A prompt handed to a
running session now says who sent it, and the card reports whether that
session took the prompt or holds it.
On a Mac, a build that you install keeps its permission answers, so macOS asks
once instead of after every build.
TL;DR
- Stop an autopilot run short of releasing
- Let a card be added and edited while its node is offline
- Let a card carry files, and hand them to every run
- Let a card be booked to run when the rate limit resets
- Stop macOS from asking for the same permission after every build
- Say who kari is on a peer send, and read the receipt
What changed
Stop an autopilot run short of releasing
Autopilot can do the work and open a pull request. It cannot merge, tag,
publish a release, push an image, or commit where a commit deploys.
An autopilot run needed a gate that a permission prompt cannot give, because
the run uses bypassPermissions and sees no prompt. kari already relays the
Claude Code hooks, so the gate is a PreToolUse hook on Bash that answers with
permissionDecision deny and a reason the run can act on.
The marker had to reach the session first. Proposal.auto said that autopilot
accepted the plan, but accept_proposal called start_card without it, so
nothing downstream knew where the run came from. Cards now carry
started_by_autopilot, and autopilot starts a card through start_card_auto.
A person who starts the same card clears the flag.
The new gate module reads intent rather than text. It splits a command line
the way a shell splits it, respects quotes, follows sh -c and nix run, and
reduces each part to a program and its arguments. So /opt/homebrew/bin/gh pr merge 1 is a refusal, nix run nixpkgs#skopeo -- copy is a refusal, and a
commit message that holds the words gh release is not.
A refusal is visible. It goes on the card, into the drawer, onto the phone and
into a notice, with the command that was tried. A silent deny would waste the
run and teach the user nothing.
autopilot_protected_paths is empty by default. Those directories belong to
the user, so this repository names none of them. Until you name one, that
fourth rule matches nothing, and there is no field for it in Settings yet.
The gate raises the cost of an accidental release. It does not stop a
determined process, and it must not be read as a guarantee. A session that
kari cannot resolve to a card is not judged, because a gate that fails closed
would block the terminal the user works in. A shell alias, a shell function, a
script on disk that holds the command, and a curl against the GitHub API all
get past it.
Let a card be added and edited while its node is offline
A node keeps the cards of its own machine, so every card write goes to the
node that owns the card. A node that sleeps, that is off the network or that
is shut down cannot take the write, and kari refused it. A person then lost
the note they typed because a laptop was closed.
The hub now holds the write instead. A new task, an edit of a card and a
delete all work while the node is away. The card is on the board at once,
marked "waiting for node", and the node row counts what is waiting. When the
node answers, the hub sends the queue, oldest first, before it publishes the
board.
The queue is in the hub's store, so it survives a restart. One card is one
write: a card added and then edited five times is one card to send, not six
calls, and a card added and then deleted leaves nothing to send at all.
An action that needs the machine is still refused with the node's name. Run,
stop, jump in, summarize and a booked run are not held, because a held one
starts work at a time nobody asked for. A move between columns is not held
either, because the column rules belong to the node. A file cannot be attached
to a card that still waits, because the file belongs on the node and the card
is not there yet.
A write the node refuses stays at the head of the queue and stops the flush
there. The writes behind it keep their order, and the node row shows the
reason. An edit of the same card rewrites the held write, so a person can
repair a card the node refuses again.
The card carries the id the hub gave it, and the node adopts that id through
the route that already restores a deleted card. A queued edit therefore still
names a card that exists, and the queue needs no new route and no change on
the node.
cargo run -p kari-core --example held_writes proves it against a real node:
it kills the node, writes a task and a note, starts the node again and prints
the card the node itself answers with.
Let a card carry files, and hand them to every run
A prompt sometimes needs a picture: a screenshot of the bug, a mock-up of the
screen. Until now there was no way to give one to a card.
Paste a screenshot into the prompt box of the card drawer, or press Attach and
pick a file. The New task dialog takes files the same way. kari names the paths
under the prompt of every run of that card, and starts the run with --add-dir
on their directory, so claude reads the files as part of the task. A card
shows a paperclip with the count, and the drawer shows a thumbnail of each
picture. One file is at most 4 MiB.
The bytes live on the node that owns the card. That follows from how Claude
Code reads a file: it opens a path on the host that runs the session. A server
was the other candidate and it fails twice. The server is optional and off by
default, so attachments would exist only for the users who run one, which
breaks rule 2 in AGENTS.md. And the node needs the file on its own disk in any
case, so a server can only ever be a hop. With a server the bytes travel down
the existing link, into the same route the node serves on loopback, and the
server keeps no copy.
Locking the node once a card holds a file was the other option, and so was
warning that a move discards the file. Neither is needed.
move_card_to_node already writes the card on the target and deletes it from
the source, so it reads the files first and writes them beside the new card. A
file that cannot be read stops the move and names itself.
The directory is the store, with no table beside it. A row and a file can
disagree, and then a card offers a file that no run can read. A name from a
client is reduced to letters, digits, ., - and _. That is not only about
the file system: the name is the last path segment of a URL on the node API,
that path travels raw inside a link frame, and Request::builder().uri()
refuses a space. Screen shot.png is what macOS names every screenshot.
The files sit outside the project, and the default permission mode refuses a
read outside the working directory, so an unattended job would block on a
permission prompt that nobody answers. Every run therefore gets --add-dir on
the card's attachment directory, and so does Jump in. A session that already
runs cannot be given the flag, so it asks once, and the prompt box says so.
kari removes the files it no longer needs, on the poll loop. An archive clears
them at once. A card marked done keeps them for attachment_keep_days, 7 by
default, because a done card can come back and a file that is gone cannot. A
directory whose card was deleted waits a day, so that the undo on the toast
still finds the files.
One fix came with this. A task card moved between the local node and a server
node was written with an empty id, because the split hub cleared the id and
restore_card keeps the id it is given. A second such move then wrote over
the first. The card now gets a new id.
Let a card be booked to run when the rate limit resets
The 5-hour window fills up, and the work that is left has to wait. Until now
the only answer was the planner, and the planner waits for a trigger. A user
who knew exactly what to run next had to come back at the reset and press
Start.
A card can now hold one booked run: a time, and an optional one-off prompt.
Press "Schedule" in the card drawer and pick the next reset of the 5-hour
window, the cycle after that, the next reset of the weekly window, or a time of
your own. Each button carries the time it would book. Text in the box at the
foot of the drawer goes with the booking as the one-off prompt. The card shows
a clock chip, the queue strip lists the booking in front of the planner's
steps, and the drawer cancels it.
The caller names a cycle, not a time, and the node resolves it from its own
rate-limit sample. The windows belong to the Claude Code account that node is
signed in to, so a phone that books a run on three nodes must not send one time
to all three. A sample can be older than the reset it names, so the resolver
steps forward one window at a time until the reset is ahead. Two minutes are
added, because resets_at is a report and not a promise. A node with no sample
says so instead of guessing: a guessed time would start the run into a window
that is still full, which is the failure the booking exists to avoid.
A booked run is a manual start with a delay, so the automation mode, the budget
and the fill ceiling do not gate it. Only the parallel cap does, and only by
making the run wait. Engine::schedule_tick reads the card rows on every
15-second poll and builds the board only when a booking is due. A run that
starts, or that cannot start, clears its booking and sends a notice. A booking
whose time passed more than 24 hours ago is dropped: a host that slept through
the reset still runs the card when it wakes, and a host that slept for a day
does not surprise the user with yesterday's work.
Stop macOS from asking for the same permission after every build
kari raised the system prompt "kari.app would like to access data from other
apps" again and again. Two separate faults caused it. One set the rate, the
other stopped the answer from holding.
A GUI app inherits no SSH_AUTH_SOCK, so kari looked for the 1Password agent
socket to reach a node over SSH. macOS holds that socket in a group container,
a group container is the data of another app, and the first look at the path
raises the prompt. The look sat inside ssh_command(), which builds every SSH
call, so every reconnect raised it again. Backoff for a node reached over SSH
stops growing at twenty seconds, so a node that was down produced about three
prompts a minute. The answer is now kept for the life of the process, so the
prompt can come only once. KARI_SSH_AUTH_SOCK names the socket directly, and
an empty value turns the search off. That suits a node that authenticates with
a key file, or with an IdentityAgent line in ~/.ssh/config.
The answer also did not hold, because macOS remembers a permission answer
against the code signature of the app that asked. A Tauri build with no signing
identity is signed ad-hoc. Such a signature carries no certificate, so the only
stable thing in it is the hash of the binary:
designated => cdhash H"4f98ea0c..."
Every build changes that hash, the remembered answer no longer applies, and
macOS asks again after each install. Three prompts came back that way: reading
the data of another app, driving the terminal, and replacing the bundle during
an update.
bun run build:mac now signs the app with a local, self-signed identity, which
scripts/mac-signing-identity.sh creates on the first run. The signature then
names the bundle identifier and the certificate, and neither changes when the
code does:
designated => identifier "io.github.lightheaded.kari"
and certificate leaf = H"..."
The certificate stays on the machine that made it. It gives no Gatekeeper trust
and it is not a substitute for a Developer ID. The command builds only the
.app and no updater artifact, because an updater artifact needs the release
signing key, which a local build does not have. CI sets no signing identity, so
the published release is unchanged.
One limit remains. The updater replaces a locally signed app with the ad-hoc
release build, so an update from a release brings the prompts back once.
Say who kari is on a peer send, and read the receipt
A prompt handed to a running session did not always arrive, and kari reported
every send as a success. Two faults in the sending code explain it.
The inbox held an anonymous sender. The receiving session sorts a sender into
one of two classes, bypass or prompting, and it holds a message from the
other class until the person at that terminal releases it by hand. The sender
states its class in an envelope around the message text, not in the JSON frame.
kari sent no envelope, so every session that bypasses prompts held the message
and waited for a click that nobody knew about.
No receipt could arrive either. The outcome comes back on a new connection to
the address in the frame's from. kari sent from: "kari", which is no
address. kari also stamped a msg_id of 32 hex characters, and the receiver
keeps the id only when it reads as a dashed UUID. So the receipt had nowhere to
go, and nothing to match.
kari now names itself and states the mode it runs the card under:
<cross-session-message from="uds:<our socket>" from-name="kari on <host>" from-mode="bypass">
the prompt
</cross-session-message>
The mode is the card's own mode, else the default from Settings. A mode whose
class kari cannot know, plan or an unknown word, states nothing. The hold is
the receiving user's protection, and a false claim would take it away. The
attribute order, the escaping of a closing tag inside the body and the cleaning
of the name all follow the receiver, because the receiver builds the envelope
again from what it parsed and drops the whole envelope when the two strings
differ.
kari binds a socket beside the socket it writes to, and it reads the receipt
there. It verifies the peer credentials of the connection first, because a
receipt carries no token. The receipt is the channel for bad news only: held,
denied, expired, refused and dropped. A message that the inbox accepts
gets no receipt at all, so silence means accepted. delivered arrives for one
case only: a held message that the user then released.
The toast and the run log now say which of the two happened. "Held for approval
in that session" is a different fact from "Sent to the running session", and
until now the two looked the same.
Install
Download the .dmg for your Mac: aarch64 for Apple silicon, x64 for Intel.
The app is not signed. After you copy it to Applications, run:
xattr -dr com.apple.quarantine /Applications/kari.app
On Windows, run kari_0.12.0_x64-setup.exe. It installs for the current user,
because kari reads the Claude Code state of whoever is logged in. That installer is
not signed either, so SmartScreen asks once: More info, then Run anyway.
An installed kari updates itself to this release: it checks on start and every six hours,
writes the new version beside the running one and offers a restart. Settings, Updates has the switch.
Every host runs the headless node, so that its sessions stay on the board while no window is open.
A Mac takes kari-node-v0.12.0-aarch64-apple-darwin (or the x86_64 one),
a Linux host kari-node-v0.12.0-x86_64-unknown-linux-gnu.tar.gz,
a Windows host kari-node-v0.12.0-x86_64-pc-windows-msvc.zip.
Run kari-node service install to keep it running at login. One engine runs on a host at a time:
open the app and the node steps down, quit the app and it takes the host back.
A node updates itself only when asked: kari-node update, or serve --auto-update.
An Android phone installs kari-latest.apk. Add this repository to Obtainium: the asset name never
carries the version and the signing key never changes, so an update installs over the previous build.
The phone reaches the nodes over a private network, such as a VPN, and pairs with the code from
Settings, Nodes on the desktop. See TOUR.md.
See the README for requirements and setup.