feat(runtime): add opt-in start on first prompt #15

Merged
kreato merged 20 commits from taha/tmux-keys-lazy-start into main 2026-10-22 18:55:34 +00:00 AGit
Contributor

Opening the TUI created a session immediately, so every launch that ended
without a message left an empty row behind. The new start_on_first_prompt
setting holds the launch on the startup surface until the user submits a
prompt, then creates the session and sends that text, so the runtime and its
sandbox start from the first message instead of from opening the UI.

The gate defers only the create path; --continue, --resume, --fork, and
session new still bind or create their session at once. Ctrl+D on the empty
startup editor now exits before any session exists. The home-manager module
exposes the setting as settings.startOnFirstPrompt and writes it as
start_on_first_prompt.

Opening the TUI created a session immediately, so every launch that ended without a message left an empty row behind. The new start_on_first_prompt setting holds the launch on the startup surface until the user submits a prompt, then creates the session and sends that text, so the runtime and its sandbox start from the first message instead of from opening the UI. The gate defers only the create path; --continue, --resume, --fork, and `session new` still bind or create their session at once. Ctrl+D on the empty startup editor now exits before any session exists. The home-manager module exposes the setting as settings.startOnFirstPrompt and writes it as start_on_first_prompt.
Prime Agent asks for xterm modifyOtherKeys mode 2 when the terminal does not
answer the Kitty keyboard query, which is what makes tmux forward modified
Enter as \x1b[27;<m>;13~. yazi only sent the Kitty query, so on tmux with
extended-keys-format xterm Alt+Enter collapsed to ESC CR, decoded as
shift+enter, and inserted a newline instead of running the follow-up binding.
Arm the same 150ms fallback after the query, cancel it when Kitty answers, and
release the mode on stop and handoff.
Opening the TUI created a session immediately, so every launch that ended
without a message left an empty row behind. The new start_on_first_prompt
setting holds the launch on the startup surface until the user submits a
prompt, then creates the session and sends that text, so the runtime and its
sandbox start from the first message instead of from opening the UI.

The gate defers only the create path; --continue, --resume, --fork, and
`session new` still bind or create their session at once. Ctrl+D on the empty
startup editor now exits before any session exists. The home-manager module
exposes the setting as settings.startOnFirstPrompt and writes it as
start_on_first_prompt.
Taha force-pushed taha/tmux-keys-lazy-start from c0eb5b211c to acf756c318 2026-10-21 16:49:18 +00:00 Compare
Taha force-pushed taha/tmux-keys-lazy-start from acf756c318 to 1ea5f30b34 2026-10-21 16:56:58 +00:00 Compare
The function-key branch tested strings.HasPrefix(k, "f"), so the plain "f" key
was treated as a function key and every binding on it with a modifier returned
false before reaching the letter handling. Alt+f, Ctrl+f, and the alt+shift+f
that the home-manager keybindings use were unreachable in tmux. Restrict the
branch to f1..f12 and cover the letter with the tmux sequences for every
configured alt+shift binding.
The roster legend rendered the configured removal key while the confirmation
prompt hardcoded "Ctrl+X again to ...", so a rebind to alt+shift+delete showed
the right key in the legend and then asked for a different one. Both now read
the same binding, and the pending-confirmation state is tracked by the pending
target instead of by matching the message text.

A reply binding that types text now follows the reference editor: it replies
only while the filter box is empty, so binding space to app.agents.reply works
without taking spaces away from a search that is already being edited. Space is
not reported as a printable key, so it needs its own spelling.
config.json now carries default_effort, written from
settings.provider.effort in the home-manager module and overridden by
--thinking. A new session starts at that level when the selected model
advertises it.

A level the model does not advertise falls back to low instead of erroring or
picking the first level the model lists, which for the standard set was off.
A model without reasoning keeps no level, so configuring a default never fails
a session. Both the create path and the runtime preference defaults share one
resolver.
Escape on a session without a runtime failed the tree read and printed the
store's "not found" at the user. That is an empty session, not a failure: an
empty tree now opens the selector, which already renders "No entries found"
with the legend and stays dismissable, matching the reference client.

setEnv treated an empty environment variable as a value, so a shell or launch
environment that exported OPENAI_BASE_URL with no value blanked base_url and
failed config validation before the UI could start.
A launch always pinned the configured provider and model on the create
request, so the daemon's own precedence - durable preference, then the last
used pair, then configuration - could never apply. Launches now leave the pair
unpinned unless the user named one, which lets the previous session decide.

The previous session's effort follows the same path: it is remembered next to
the model and resolved against the new model, so a level that model cannot
express falls back instead of failing the session.

remember_last_selection (settings.rememberLastSelection, on by default) turns
the behaviour off and restores strict configuration precedence for both the
pair and the effort.
Approving a host directory for a runc session could end three ways: the path
was missing from the container, the runtime failed to start, or the agent never
learned the decision.

Grant mounts came from a loop that emitted every allowed path unchecked. A
missing host path, a pattern, a duplicate destination, or a path already inside
another mount either failed runc or left an empty directory where the user
expected their files. Mounts now come from one helper that validates the
source, keeps the widest access for a repeated target, and skips what the
container already covers.

Resolving an approval refuses what the local runc sandbox cannot bind, so the
prompt fails with a reason instead of accepting a grant that does nothing.

Approving rebuilds the session runtime, which closes the actor that asked the
question. The decision was submitted to that closing actor and dropped: the
sandbox had the mount and the agent still reported it could not see the
directory. The resume path now waits for the replacement actor.

A session-scoped approval rebuilds only its own session, so an unrelated
session with background processes no longer fails the decision.
A request_access call for a path that is not on the host still produced an
allow/deny prompt. Approving it changed nothing, because nothing can be bound,
and the agent had to discover that afterwards.

The request tool now consults the host first: a missing, empty, or relative
path is returned to the agent as an error naming the path, and only a path that
exists reaches the user as a prompt. The check runs only for filesystem
requests, and only when the session's files really are this host's files: a
remote execution endpoint owns its own filesystem, so the local stat would
answer for the wrong machine.
A background process completion was persisted as a user message, so it showed
up wherever user input is listed: the session tree's user filter, fork and
rewind selection, and the transcript's user bubbles. The notification is not
something the user typed.

Wakes are now stored as a system notice with no role. The provider projection
still replays the text as a user turn, so the completion keeps driving the
agent, while the user-message channel holds only what the user wrote.

Agent-to-agent messages keep their current storage; only the automatic wake
class moved.
The paste action reported that the native host does not support clipboard
images, so the feature was unreachable on every platform.

Reading follows the reference implementation: Wayland asks wl-paste for the
advertised types, X11 reads xclip targets with xclip as the fallback under a
Wayland session, macOS uses pngpaste, and Windows asks PowerShell for the PNG
bytes. Only png, jpeg, webp, and gif are accepted; anything else is treated as
"no image" so the paste stays silent instead of failing the next request.

Every helper runs under a context with a deadline and a bounded buffer, and the
seam makes the parsing testable without a live clipboard. Resizing is a
pass-through until a resizer is linked, which keeps the paste working.
A request_access call for a path that is not on the host still produced an
allow/deny prompt. Approving it changed nothing, because nothing can be bound,
and the agent had to discover that afterwards.

The request tool now consults the host first: a missing, empty, or relative
path is returned to the agent as an error naming the path, and only a path that
exists reaches the user as a prompt. The check runs only for filesystem
requests, and only when the session's files really are this host's files: a
remote execution endpoint owns its own filesystem, so the local stat would
answer for the wrong machine.
A background process completion was persisted as a user message, so it showed
up wherever user input is listed: the session tree's user filter, fork and
rewind selection, and the transcript's user bubbles. The notification is not
something the user typed.

Wakes are now stored as a system notice with no role. The provider projection
still replays the text as a user turn, so the completion keeps driving the
agent, while the user-message channel holds only what the user wrote.

Agent-to-agent messages keep their current storage; only the automatic wake
class moved.
The paste action reported that the native host does not support clipboard
images, so the feature was unreachable on every platform.

Reading follows the reference implementation: Wayland asks wl-paste for the
advertised types, X11 reads xclip targets with xclip as the fallback under a
Wayland session, macOS uses pngpaste, and Windows asks PowerShell for the PNG
bytes. Only png, jpeg, webp, and gif are accepted; anything else is treated as
"no image" so the paste stays silent instead of failing the next request.

Every helper runs under a context with a deadline and a bounded buffer, and the
seam makes the parsing testable without a live clipboard. Resizing is a
pass-through until a resizer is linked, which keeps the paste working.
Only text the user types in the editor is a user message. Wakes the harness
generates were still stored as user messages because the split keyed off prompt
prefixes, which covered background completions but not goal continuations,
autonomy prompts, the sandbox decision follow-up, subagent completion notices,
or a coalesced batch of them.

The actor now has an explicit notice submission, and every automation call site
uses it: goal continue, goal retry, autonomy start, the sandbox decision
resume, process completion wakes, and subagent completion notices. The prefix
classifier stays as a fallback for a wake that arrives through the queue.

Agent-to-agent messages and a subagent's delegated task prompt stay user
messages: they carry card metadata and are the instruction the child works on.
Image support was derived per provider: the NanoGPT registry looked for a
"vision" capability string, and the OpenAI-compatible path only understood a
top-level "input" list. A model that publishes its modalities another way was
catalogued as text-only, so the paste path warned about a model that does accept
images.

One reader now understands the published shapes - input and input_modalities
lists, modalities.input, architecture.input_modalities, a "text+image->text"
modality string, a capabilities object with vision, a capabilities list, and
supports_vision - and a declaration always wins over a configured fallback. The
NanoGPT registry and the generic discovery both use it.

NanoGPT additionally merges the served models' detailed endpoint, because the
curated registry can lag behind the list the gateway actually serves.
A connection snapshot carries the model id and the session's thinking levels,
but not its modalities. The catalog merge that fills them in returned early
whenever the snapshot already had thinking levels, which the daemon always
sends, so the current model looked text-only and every image paste warned about
image support even though the turn accepted the image.

The merge now always replaces the sparse snapshot model with its catalog entry
and keeps the daemon's thinking levels. The paste path asks one helper that
prefers the merged state and falls back to the catalog, so an early snapshot
before the catalog loads cannot produce the same false warning.
A sandboxed kubectl or talosctl ran without its configuration and failed with
an error that said nothing about the missing host directory. The harness now
knows which commands read configuration and where: kubectl, kubeseal, k9s,
helm, talosctl, docker, podman, aws, gcloud, az, gh, ssh, scp, rsync, git, npm,
pip, terraform, tofu, psql, and mysql.

Before such a command runs, every existing host config path is checked against
the stored decisions: an unread path raises the usual filesystem.read request,
and the command stops with an explanation naming the paths instead of running
blind. The request id is the same stable hash the access tool uses, so an
exact approval matches the retry.

The permission guide also states the rule for host paths: never search the
sandbox for a path the user names - pass it to request_access and the harness
answers from the host.
Git keeps its configuration in ~/.config/git as often as in ~/.gitconfig, so
only the legacy path was offered and the sandbox ran git without its identity
or signing settings.

The SSH agent was taken from SSH_AUTH_SOCK alone, which is the wrong socket when
a signing tool serves its own: 1Password signs with op-ssh-sign against
~/.1password/agent.sock, named in ssh_config as IdentityAgent. host.ssh now
resolves SSH_AUTH_SOCK, then the ssh_config identity agent, then the 1Password
socket, and binds whichever is live.

When the host git configuration signs with SSH, the preflight also requests
host.ssh, so a commit signs instead of failing inside the sandbox.
A sandboxed session could not ask for host access at all. The agent looked for
a model tool, then probed host request names (host.access, fs.read,
host_operation, request_access) and reported that the tool was unavailable -
request_access is a kernel function, so neither lookup could succeed.

The rlm facade now carries request_access and host_operation, which is where an
agent looks first, and the permission guide states the exact call, that these
are kernel functions rather than tools or RPC methods, and that other host
request names must not be probed.
Asking for access left the agent running: it kept working or probing while the
prompt waited, and the decision then arrived into a turn that had moved on.

Raising a request now cancels the session's turn, so the agent stops at the
question. The decision still travels as a harness notice - "[sandbox access
approved/denied] ..." - and starts a fresh turn that carries the instruction to
continue with the mounted path or to stop trying the denied operation.

The cancel runs asynchronously so the tool call that raised the request never
waits on its own interruption.
Asking for access left the agent running: it kept working or probing while the
prompt waited, and the decision then arrived into a turn that had moved on.

Raising a request now cancels the session's turn, so the agent stops at the
question. The decision still travels as a harness notice - "[sandbox access
approved/denied] ..." - and starts a fresh turn that carries the instruction to
continue with the mounted path or to stop trying the denied operation.

The cancel runs asynchronously so the tool call that raised the request never
waits on its own interruption.
Storing an automatic wake as a custom_message entry took the interactive host
down: the durable projection only knew message and ui entries, so it returned
"unsupported native entry kind" and the whole transcript failed to load.

custom_message now decodes into a hidden custom entry, so the notice stays out
of the chat and the user channel while the transcript loads. Unknown kinds still
fail loudly, which is what the existing projector contract asks for.
The flow the harness now states and enforces is: the agent asks one direct
question about the path the user named, and only a path that exists becomes a
permission request.

host_path (kernel function, also rlm.host_path) calls the new host.path request:
one call, an exists/kind answer, no prompt, no sandbox search, and nothing on a
remote endpoint where only its own host can answer.

A missing path is answered plainly and the guide forbids the parent-directory
detour: the previous message suggested requesting the parent, which invited a
grant the user never asked for. Only the exact named path may be requested.
Approving a second grant for the same directory, or for a child of one already
granted, failed the whole activation with "duplicate sandbox mount target": the
dedupe helper returned the existing mount and the caller appended it again.

The helper now reports a covered target, and the caller refines the existing
mount instead of adding one; a later read-write approval widens access rather
than being downgraded by the earlier read-only mount.

request_access also answers a path already covered by a granted parent as
"already attached" - after running activation, so a runtime that lost the mount
is repaired - instead of asking the user again.
Every shell command started as a tracked background process, so each exit
queued a "[background processes completed]" notice and the next cell step could
begin before the output existed.

bash() now waits for the exit and returns the result; the completion notice is
suppressed up front for such a command, because the caller is the one reading
it. background=True keeps the old handle behaviour for long builds, tests, and
downloads, and only those exits wake the agent.

The execution prompt states the synchronous contract instead of teaching that
every command returns a handle.
git pull over an SSH remote failed with "Host key verification failed": the
sandbox had no known_hosts, no ssh config, and no agent.

The preflight now offers only the public SSH files - ~/.ssh/config and
~/.ssh/known_hosts - alongside git's own configuration; private keys are never
requested. Commands that may open an SSH connection (git, ssh, scp, sftp, rsync)
also request host.ssh, so the transport uses the host agent that ssh_config or a
signing tool names instead of failing authentication. The identity agent is
still resolved from SSH_AUTH_SOCK, then IdentityAgent, then the 1Password
socket.
Taha force-pushed taha/tmux-keys-lazy-start from ff44a682c8 to 870aabd3e7 2026-10-22 15:47:55 +00:00 Compare
A git pull over an SSH remote asked for the ssh files first and the agent after,
so the transport either prompted twice or failed host-key verification.

The preflight now decides SSH first: git, ssh, scp, sftp, and rsync request
host.ssh before any configuration path, and the ssh_config/known_hosts entries
leave the configuration table because one host.ssh approval provides them. The
sandbox build binds the resolved agent socket and, read-only, the public
~/.ssh/config and ~/.ssh/known_hosts, so host key verification and identity
selection both come from that single grant. Private keys stay on the host.
Taha force-pushed taha/tmux-keys-lazy-start from 0bf3500d4f to 63c2368a7b 2026-10-22 16:04:12 +00:00 Compare
extra_dns replaced the resolver outright, so a sandbox pointed at a tailnet
resolver could not resolve anything: 100.100.100.100 answers SERVFAIL to
queries that do not arrive over the tailscale interface. It also dropped the
host's search domains, so MagicDNS short names like raspberry-190502 had no
domain to append.

The generated resolv.conf now starts with slirp's resolver (10.0.2.3), which
forwards through the host's resolver chain and therefore sees tailnet names,
then lists the configured servers as fallbacks, then carries the host search
domains, then options edns0.
A host.ssh approval bound ~/.ssh/config and ~/.ssh/known_hosts at their host
path, but the sandbox shell resets HOME to /root, so ssh read /root/.ssh,
found no known_hosts and no alias, and failed with "Host key verification
failed" - rsync of the file was impossible because /root is read-only.

The grant now binds both files at the host path and at /root/.ssh and
/home/agent/.ssh, the two places a login shell can end up. The mount helper
takes an explicit target for that, still rejecting relative and built-in
targets.
MagicDNS names never resolved inside the sandbox even though the resolver was
reachable and the search domain was present.

Order was the cause. The slirp resolver was listed first; it forwards only
through the host's global servers, which do not know tailnet names, and it
answers NXDOMAIN. glibc stops at the first NXDOMAIN, so the tailnet resolver
further down the list was never asked. Configured servers now come first - they
own the names the operator configured them for - and the slirp resolver stays
last as the fallback that a SERVFAIL falls through to.

Verified in a live session container by rewriting its resolv.conf in place: with
the tailnet resolver first, both raspberry-190502 and its tail4660e6.ts.net FQDN
resolved to 100.100.30.1 while github.com kept resolving.
ssh refused the host config with "Bad owner or permissions". The host's ssh
config is a Home Manager file in the Nix store, owned by host root, while the
container maps only the daemon user's uid, so the file arrived owned by an
unmapped uid. The directory itself was then unreadable ("Operation not
permitted"), and known_hosts sat on a read-only bind.

The grant now copies config and known_hosts into a sandbox-owned directory under
the state dir, forces 0600 files inside a 0700 directory, and binds that
directory at /root/.ssh and /home/agent/.ssh read-write, so ssh sees its own
files and can still record a new host key.
The agent's first SSH attempt was a one-shot host operation, which ran an ssh
client on the host with no agent, config, or known hosts and failed after the
user approved the wrong thing.

host_operation now refuses ssh, scp, sftp, and rsync argv and answers with the
route to take: approve host.ssh once, then run the command with the bash tool,
where the agent, ssh config, and known hosts are all present. The permission
guide states the same rule, so SSH always starts at host.ssh.
Taha force-pushed taha/tmux-keys-lazy-start from 9eaf902918 to 77f36269b8 2026-10-22 17:20:18 +00:00 Compare
kreato merged commit e336fe2e5c into main 2026-10-22 18:55:34 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kreato/yazi!15
No description provided.