Commit Graph

454 Commits

Author SHA1 Message Date
NCBM 0d71923472 tools: run batch continuations where their parent ran 2026-11-02 14:52:57 +08:00
NCBM 9027901168 tools: check every prompting tool's arguments before the prompt 2026-11-02 14:47:50 +08:00
NCBM 2729f12d26 tools: answer a call's shape before the confirm prompt 2026-11-02 14:34:30 +08:00
NCBM 5c13d10989 tools: model batch steps as nested executions 2026-11-02 14:21:26 +08:00
NCBM 8cbb98602e cli: report each prompt's own denial
The prompts knew the difference already — one branch printed "timed out",
another fell through to a plain no — and then threw it away by returning
False/None.

Each returns a `Denial` now: the timed policies name their deadline, a cancel
is its own answer, and a non-interactive run says nobody could be asked. The
batch decision and the skill write decision carry it too.
2026-10-29 23:43:20 +08:00
NCBM 4c67f6bb04 tools: carry the denial reason into the tool message
The four policy gates phrased their refusal themselves, and all four said
"declined or timed out". They read the `Denial` their prompt returned instead:
the command denylist, the private-host fetch policy, the skill write and the
directory-access request each name the answer they got.

`get_fetch_policy_confirm_hook` no longer coerces the answer to `bool`, which
would have reported a timeout as an allow.
2026-10-29 23:43:04 +08:00
NCBM 79ccf593f6 core/agent: name why a confirmation answered no
A refused tool call read "denied by user confirm", whether the human had said
no or the prompt had timed out — the model was told a decision it never got.

`Denial` (`agent/tools.py`) carries the kind — declined / timeout / cancelled /
no prompt — plus the deadline and the human's comment, and `Denial.clause()`
spells it once for every message that needs it. The soft gate and the batch
decision pass it through, so a timeout says its deadline and a missing hook
says nobody was asked.
2026-10-29 23:42:29 +08:00
NCBM 7b3f55ea65 cli: leave a directory-access request to the human under YOLO
The access hook short-circuited on YOLO mode and answered its own question
with a process-only grant, so `--yes` (and `/yolo on`) reached any path outside
the workspace without asking. That grant is a filesystem capability, not one
risky call: the command denylist and a skill write are asked either way, and so
is this one now.

`yolo_allow` stays as the process-scoped store a host fills when it approves
without persisting; nothing auto-approves it any more. A non-interactive run
denies, like the other policy prompts.
2026-10-29 23:19:38 +08:00
NCBM ed8013d6ce cli/display: read past the approval line when rendering a detail
The line is for the model, and the human already saw the prompt. A detail that
parses the result (write_file's char count) must not see a header in front of
it; the verbose `[tool ok]` view keeps the whole text.
2026-10-29 18:29:20 +08:00
NCBM 05166f2f76 tools: record the policy answers that let a call through
The command denylist, the private-host fetch policy and the skill write grant
asked the human and then said nothing about it in the result.

Each now records the answer for the running call: `user (process)` for this
call's override and `grant (process)` when an earlier one already covers the
program or host, `user (once|session)` / `grant (session)` / `config (policy)`
for a skill write. `request_directory_access` is unchanged: its own text already
names the level and scope it granted.
2026-10-29 18:28:48 +08:00
NCBM f9ec812275 core/agent: lead a confirmed tool result with how it was approved
A tool result said nothing about the answer that let the call run: the human
approved a delete, the model saw only "deleted x", and a resumed transcript
could not tell a YOLO auto-approval from a prompt.

The soft gate now returns an `Approval` (`user` / `grant` / `yolo` / `config`
with the scope that answer reaches) and `execute_output` leads the result with
`[approval] user (once)`. A handler that prompts on its own records its answer
through `tools/context.py` (`collect_approvals` around one call), and the one
line printed is the answer closest to a human. A call that needed no
confirmation, a denial and an `error:` result carry no line.
2026-10-29 18:28:13 +08:00
NCBM 7abc7c2cb8 core/agent: lead a ceiling-cut tool result with its dropped-tail notice
The storage ceiling's notice trailed the head it was cut from, and the wire
cap sends the head: the only way to learn that a result had been cut was to
fetch a 2 MB body back through its document, which reads to the very end.

The notice now leads, says the tail is not readable, and `cap_tool_message`
names the same fact in the document marker, so neither the head nor the marker
presents a cut body as the whole output.
2026-10-29 15:17:12 +08:00
NCBM 7d29498dfa docs: describe the document model for tool results
AGENTS.md, README, and /status now describe the request-prep cap and the
document tools (`read_document` / `search_document`). /status also shows the
storage ceiling.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 14:24:35 +08:00
NCBM 140268f983 tools/truncate_token: scope the token cursor to file reads and mentions
Tool results no longer use a truncate token, so the shared `truncate_generic`
wrapper goes. `read_file` keeps its file token, and a user `@path` mention
keeps the memory token (its inlined text has no re-readable source).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 14:16:40 +08:00
NCBM 7d6e367143 tools: keep tool output whole and let the request-prep pass cap it
run_argv, run_argv_batch, fetch, fetch_local, and the vcs tools now return
their output whole, so the request-prep cap trims it on the wire and names
its document. A fetched page or a long log reads on from the tool message,
with no re-fetch and no re-run.

The run_argv_batch run budget stays as an execution guard: it stops a
runaway batch from spawning further huge-output steps.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 14:13:36 +08:00
NCBM 63109ef9f2 core/agent: cap tool results in the request-prep pass
A tool result now stays whole in its message (up to a storage ceiling) and
is trimmed on the request copy by the request-prep pass, where soft compact
already runs. The trimmed copy names the document that holds the rest, so
every surface (chat-completions, Responses, Messages) shares one rule.

New [agent] hard_max_tool_result_chars bounds the stored body; the wire cap
stays [agent] max_tool_result_chars.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 10:04:32 +08:00
NCBM dfaaad563c tools/documents: add document tools over capped tool results
A tool result stays whole in its own tool message; a request-prep marker
names a document_id (the message's tool_call_id). read_document,
search_document, document_info, and list_documents resolve that id back to
the message body, so reading and searching a capped result needs no re-run.

The store binds on the host instance and reads the agent's live history,
which carries whole results and re-reads whatever a resume reloaded.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-29 02:36:36 +08:00
NCBM 741d1a8526 core/cli: keep the limit prompt line on the terminal
Once the limit prompt offered its three answers as notification buttons, the
read took the button path and skipped the prompt line the terminal used to
print: the human at the terminal saw the reason and then an indefinite wait,
with nothing saying what to type. It writes the line again, as every other
prompt that reads both surfaces already does.
2026-10-26 00:52:48 +08:00
NCBM fb77fd676b core/tools: point a refused write at a temporary workspace
A path outside the workspace is usually rejected while the model only wants a
scratch file: the refusal named the grant tool, so the model had to ask a human
for access it never needed. A write now also names `new_temporary_workspace`,
whose path needs no grant. Reads and commands keep the grant hint alone.
2026-10-26 00:52:28 +08:00
NCBM 2039d4f31f core: rebind the lineno tracker when retry continues a turn in a new task 2026-10-25 00:19:01 +08:00
NCBM a7abc50aa5 core/cli: drop the go-to-terminal button and ring the bell when a popup goes out
"Go to terminal" promised something no desktop can do — raise another
application's window — and the popup's own close button does as much, so it
was a third button that answered nothing. `REVIEW` leaves the answer
vocabulary; a click on the body (the platform's default action) or a close
still withdraws the notification and leaves the prompt waiting.

The bell now rings as the notification is shown. A prompt is only announced
after `grace_seconds` of nobody answering it, so the human is at some other
window — possibly another virtual desktop, where a popup is easy to miss — and
the terminal's bell is the only portable cue that says where to look. It rings
once per announcement; withdrawing the popup afterwards stays silent.
2026-10-24 04:40:39 +08:00
NCBM ea0827ddfb core/cli: name the workspace, let the popup expire, ring the bell
A notification said which prompt was waiting but not which chat, and two
windows look alike. The body's last line is now the workspace the chat runs
in, with the home directory shown as `~`, and it follows a session resumed
into — or rebound to — another directory: `ReplState` retargets the installed
notifier instead of freezing the directory the process started in.

A timed prompt has a deadline, the popup did not, so it could stay on screen
announcing an answer window that had already closed. The deadline is now the
popup's own timeout (rounded up to whole seconds); a prompt without one keeps
the old "until dismissed" behaviour.

"Go to terminal" (and a click on the body) withdrew the notification and left
the prompt waiting, but nothing showed the human *where*. No desktop lets a
program raise another window — Wayland has no such call and the X11 ones need
a helper per WM — so the terminal rings its bell instead.
2026-10-24 04:35:04 +08:00
NCBM 04d34b34f3 core/cli: name what a confirm is about, and when it denies itself
A tool confirm's notification said which tool asked and nothing else, and
the three timed prompts (denylist, directory access, skill write) hid the
clock: each of them denies on timeout, so a human reading the notification
late was answering a prompt that had already given up.

`_tool_facts` lifts the object out of the call — the program's name, or the
file's — and never an argument, a URL or a piece of code: those stay in the
reason, which only travels with `include_reason`. The timed prompts pass
their timeout as `expires_in`, and a batch names the tools it asks about the
same way instead of keeping them behind the reason.
2026-10-24 04:21:37 +08:00
NCBM 9ed4620579 core/cli: render a prompt's body from what it declares
The body was the reason line or nothing, so with the default
`include_reason = false` every notification was a bare title. It is now the
question, then the reason when the config allows it, then what the prompt is
about, and last the deadline — filled in that order until the budget is gone,
so a long reason drops out before "no answer in 30s = deny" does. A desktop
popup wraps and keeps the lines apart; a terminal escape sequence carries them
on one line.

Two answers were missing from the surface: a reply prompt had no button at all
(a question could not be sent back to the terminal), and the notification body
was not clickable. Clicking it is the freedesktop default action, so it now
withdraws the notification and leaves the prompt waiting — the same as the
"Go to terminal" button. A typed reply is recorded by `reply()`, which keeps a
reply that spells "review" an answer; a secret is never announced at all.
2026-10-24 04:21:25 +08:00
NCBM 5f7a70bf84 core/prompting: let a prompt say what it asks and when silence answers
`PromptWait` only had a short title and the reason line behind
`include_reason`, so a host that keeps the reason out had a bare title to
show — and a question, whose text *is* what is asked, was cut to the title's
few words. It now carries `body` (the question itself), `facts` (the object:
a program or a file name, never an argument) and `expires_in` (when the
default answer takes over). `ask` and `choose` put their question in the body
and a choice names its options; a secret says in its docstring why its prompt
is announced but never answered.
2026-10-24 04:21:14 +08:00
NCBM 36e37e2a6f core/cli/display: show what a literal PTY write sends
`write_pty` printed only `* PTY Write 3 (5 chars)`, so the one line describing
typed text said nothing about it — the keys line at least named its markers.

Both write tools now share one prefix: the payload, rendered with `repr` so it
stays on one line and shows what is invisible (`'ls -la'`, `'a\nb'`, an
all-spaces payload that is not mistaken for an empty one), shortened to 40
characters. `ask_into_pty` keeps the payload-less line: the human's answer is
the one thing that must not be shown.
2026-10-24 02:13:37 +08:00
NCBM 87f7166a77 core/tools: spell modified keys the way xterm does
The marker table only knew unmodified keys, so `<s-up>` and `<c-f5>` were
errors and the fallback was raw bytes: a TUI that wants Shift+Tab and
Ctrl+arrows could not be driven without hand-writing `\x1b[1;5A`.

A marker may now carry `c-`, `m-` (or `a-`), and `s-`, alone or combined, and
keys that have an xterm form — arrows, Home/End, Ins/Del, PgUp/PgDn, F1–F12 —
expand to the CSI sequence with the 1 + shift/alt/ctrl parameter. `<m-up>` is
`\x1b[1;3A`, not an ESC in front of an arrow, which no terminal sends. On a
plain character the old spellings stay: Ctrl is the control byte, Alt an ESC
before the character, and Alt alone still prefixes a key without a CSI form
(`<m-tab>`). `<ins>`, `<insert>`, and Ctrl+Space (NUL) come along; a modifier a
key has no sequence for is still an error rather than a guess.
2026-10-24 02:06:51 +08:00
NCBM a2d8159267 core/cli/display: show what write_pty_keys sends
Its pretty line read `* PTY Keys 3 (1 char)` — the one tool whose whole job is
choosing keys was the one that never said which. The markers are already
readable, so the payload goes into the prefix as `* PTY Keys 3 '<c-c><enter>'`,
flattened and shortened like the other subject previews; the outcome stays the
byte count.
2026-10-24 02:03:26 +08:00
NCBM 9c6222d742 core/tools: read keys as <name> markers in write_pty_keys
The grammar had grown three notations for the same thing — a backslash escape
(`\e`, `\t`, `\x1b`), a `ctrl+x` name, and a `key=esc|up|…` marker — so ESC had
five spellings and code and docs already disagreed on one of them
(`key=escape`). `key=` also looked uncomposable while being the only way to
send a key twice (`key=upkey=up`), and anything unrecognised was written to the
terminal verbatim: `key=hme` typed those seven characters into a shell prompt,
in a tool that never prompts.

Markers are now one form — `<name>`, `<<` for a literal `<` — with named keys
through `<f12>`, Ctrl and Alt as `<c-a>` / `<m-x>`, `s-tab` for the reverse Tab
every TUI uses, and `<up*3>` to repeat. The name table lives in one place, and
an unknown or badly repeated marker raises instead of being typed: a typo is an
error the model can fix, not bytes the human has to notice. `ctrl+` and `key=`
are gone rather than kept as aliases, so nothing has two spellings again.
2026-10-24 02:03:13 +08:00
NCBM bf3a01854c core/cli: stop repeating the prompt in the notification body
Both channels give the title a field of its own, so a body carrying the same
line says it twice — a desktop notification showed "Allow 'rm -rf /'?" as its
title and again underneath. The body is now only the one-line reason, which is
what `include_reason` is for, and nothing when it is off.

OSC 9 joins title and body with a colon, so a body-less one is formatted without
the separator instead of leaving it dangling.
2026-10-24 01:53:38 +08:00
NCBM 3a54745d01 core/cli+prompting: let a prompt name the answers its notification offers
Every button-bearing prompt offered the same three answers, so the ones whose
terminal read takes more than yes or no lost theirs the moment the human
answered from the notification: the limit prompt could not say "stop asking this
turn" (`yyy`), the batch confirm could not say "one at a time" (`m`), a skill
write could not say "this chat" (`s`), and the limit prompt could not be
answered at all — its wait was not even consulted.

`PromptWait` now carries `choices` (a key the terminal read accepts, plus a
label a human reads), `prompt_wait` passes them through, and the notifier sends
them as buttons, appending "Go to terminal" as the way back. The limit prompt
reads both surfaces like a timed confirm does, and answers that used to be
terminal-only tokens arrive as themselves.

A prompt that follows an answer given on the notification — the "why denied"
comment, the per-call prompts after "Review one by one" — is announced at once:
the grace period and the burst throttle exist for prompts the human has not
noticed yet, and they have.
2026-10-24 01:48:22 +08:00
NCBM b7044bbba7 core/cli: keep a prompt notification on screen
A prompt notification went out with the library defaults: the notification
server's own expiry. A desktop server drops such a notification after a few
seconds, so the announcement of a blocked turn had usually slid into the
notification tray by the time the human looked back.

It now goes out without expiry (`timeout=0`, the freedesktop value for "until
dismissed"). Urgency is left alone: measured on KDE Plasma, `critical` keeps the
notification up just the same, but paints it as urgent and asks the platform's
Focus mode to let it through — decoration a prompt does not need. Dismissing is
already the prompt's own job once it is answered, so nothing lingers.
2026-10-24 01:48:04 +08:00
NCBM 12236cffb5 core/tools: read each wrapper's options with getopt
Four hand-written tables said which wrapper options take a value, and the
comment above them admitted the failure mode: an entry for a flag eats the
program behind it, which is how `exec -c rm -rf /` hid `rm`. Guessing was the
other half — an option nobody listed was skipped bare, so the token after it
became the program.

Each wrapper now carries a `getopt` spec instead (`command_scan._WRAPPERS`:
short options with `:`, long options with `=`), which spells flags and values
apart, understands jammed forms (`-p2222`, `--with=pkg`), `--`, and unique long
prefixes, and stops at the first token that is not an option — exactly the
wrapped program. The remote clients and the `run` launchers are the same thing
with a flag on the spec.

An option the spec does not know is no longer skipped as if it were a flag: the
unwrap stops at that wrapper (`unparsed`) and the review asks — `options not
understood sudo (cannot read its options: option --new-sudo-flag not
recognized)`, with the whole argv. The wrapper itself stays the command the
denylist judges, so a new `sudo` flag cannot hide `rm` from the hard gate.
2026-10-23 10:47:36 +08:00
NCBM 3cb4d490ed core/tools: announce a remote shell instead of judging it
`ssh prod` runs no command this scan can resolve, and what is typed or piped
into the shell it opens never reaches any gate — `write_pty` and
`run_argv(stdin=…)` are both unchecked input. The tool description said nothing
about it, so the call looked like any other.

The hop is now reported even when no command follows
(`RemoteTarget.shell`), and the review announces it: `remote shell on 'prod'
(ssh with no command; its input is not checked)`. It is a soft confirm like the
other reviews (YOLO may skip it, an escalation behind a hop still may not) — the
client itself stays outside the denylist, since there is no program to name.
2026-10-23 10:43:05 +08:00
NCBM 0cb740393e core/tools: judge an ssh command by what runs on the far end
The gate judged `ssh` itself: `unwrap_command` knew env wrappers and
`<launcher> run`, not a remote client, so `ssh prod rm -rf /` reached
`check_command_allowed` as "ssh" — not denylisted — and `classify_danger` found
neither an interpreter nor a detached run. The command ran unannounced.

`ssh` / `mosh` are wrappers now: their own options are skipped, the first token
that is not an option is the destination, and the program under review is the
one after it (after `--` for mosh). The hop is reported on the scan
(`RemoteTarget`), so `ssh prod bash -c …` is reviewed with the code it carries,
an escalation behind the hop keeps its confirm through YOLO, and the denylist
matches the remote program (`rm`, `sudo`, `systemctl reboot`).

`-o BatchMode=yes` cannot prompt at either end, so a command running with it is
always confirmed, whatever program it is.

A client with no command only opens a remote shell: nothing to judge at spawn
time, and what is typed or piped into that shell never reaches the scan.
2026-10-23 10:37:04 +08:00
NCBM 80ad34f8fe core/cli+prompting: hear the notification while a prompt waits
Whether a prompt could be answered from its notification was decided by
`notify_desktop`, which only runs once the grace period is over — by then the
prompt had already committed to the plain terminal read. A pressed button or a
typed reply landed on a queue nobody read, and the human had to come back to the
terminal to answer the prompt the notification was showing.

A wait now asks up front whether an answer may arrive (`answer_capability`: the
kind, `actions`, the channel and the platform's capabilities) and listens on both
surfaces from its first poll. The desktop channel is resolved once, in the
background from `install_notifier`, so the capability probe never lands on a
prompt that is already waiting.

`confirm()` takes an optional `source` (the form's submit confirm and every
limits.py confirmation go through it), which also leaves one place — not two —
parsing `allow` / `deny` into a yes or no.
2026-10-23 10:24:46 +08:00
NCBM da6f4b7dd7 core/tools+agent+cli: refuse a picture the model cannot take
`[providers.*.models.*].image_in` gated only the `@path` mention: `view_image`
attached whatever it was asked for, and a tool result's picture (an MCP server's
`image` content, a plugin's `ToolOutput`) went out the same way. On a model that
does not read pictures the provider answers `[Unsupported Image]` one round
later, and a gateway that cannot translate image blocks hands the model raw
base64 — the model then reports "I can only see a base64 payload", which reads
like the picture arrived when it did not.

The verdict now lives on `InstanceState.workspace` (`image_in` + the
`(provider, model)` it came from) and `tools.workspace.image_input_refusal`
turns it into the one message every path shares:

    error: model 'deepseek-v4-pro' does not accept image input
    (set image_in = true under [providers.deepseek.models."deepseek-v4-pro"])

`view_image` and `fetch_local` refuse with it before reading any bytes (the
fetch keeps its metadata line, so `body_kind=image` and the size stay visible);
the registry drops an image from a `ToolOutput` it did not vouch for and appends
the same reason to the text. That last one is the safety net for MCP and plugin
tools, which know nothing about the model.

`ReplState.sync_image_capability` publishes the verdict and runs from
`rebuild_client`, so `/provider` and `/model` flip the gate immediately, and
`_run_chat` seeds it before the first turn. A host that binds no instance
declares nothing and keeps sending pictures, as before.
2026-10-21 21:39:24 +08:00
NCBM 4b90718191 core/config: re-read [skills] on reload
`ConfigStore.reload()` re-parsed every section except `[skills]`, so
`/model --persist` and any other in-place reload kept serving the skill config
the process started with — including `[skills].enabled`, which is what decides
whether the skill tools exist at all.
2026-10-21 20:26:08 +08:00
NCBM 0e8b0f8bd4 docs: describe deletes that go to the trash
A new page, `doc/trash.md`, covers what a delete does now: where a path lands on
each platform, what is still deleted outright (an explicit `permanent=true`, a
host that cleared `[trash]`, scratch paths under the OS temp dir, the
`copy_path` / `move_path` overwrite, the temporary-workspace cleanup), what
happens when a trashing is refused — the file stays and the error says how to
delete it on purpose — and the two things the trash cannot help with: a shell
`rm` and anything a plugin or MCP server does on its own.

`[trash]` joins the example config and the README snippet, the README gains the
one-paragraph version, `AGENTS.md` the tool/config notes (including the confirm
wording a delete now uses), and the default tool playbook tells the model that
`delete_path` is undoable and what `permanent=true` costs.
2026-10-21 20:25:27 +08:00
NCBM 2369592473 core/tools+cli/display: name the trash in the delete confirm
The box asked "delete path 'x'", which reads as final even now that it is not.
It now says "move path 'x' to the system trash" — and "delete path 'x'
permanently" when the path really is destroyed: an explicit `permanent=true`, a
host that turned `[trash] enabled` off, or a scratch path under the OS temporary
directory. The classifier resolves the path to answer that, and falls back to
the trash wording when path policy will refuse the call anyway.

The pretty line follows: a delete that landed in the trash prints `(to trash)`
instead of `(done)`, so a session's transcript still shows which deletes could
be undone.
2026-10-21 20:23:54 +08:00
NCBM 17813281d8 core/cli: hand the trash switch to the tools
`[trash] enabled` now reaches `delete_path` through the bound instance's
workspace policy, the same channel `[agent] path_denylist` already travels: set
when the session state is built and re-read by `reload_config_from_disk`, so a
config reload in the middle of a chat changes the next delete too.

The policy flag is what `delete_path` consults, so a host that never sets it
gets the safe default (deletes go to the trash) instead of a silent no-op.
2026-10-21 20:22:50 +08:00
NCBM c4a287c464 core/config: add the [trash] section
`[trash]` owns the one policy `delete_path` needs from the human: whether a
delete is a move to the desktop's trash (`enabled = true`, the default) or a
removal that cannot be undone (`false`, which is what the tool did before).

Unknown keys degrade to the defaults, the way `[mcp]` does, so a typo cannot
silently switch a destructive policy on. `reload()` re-reads the section.
2026-10-21 20:19:54 +08:00
NCBM 7b7c90b8ee core/tools: send a deleted path to the system trash
`delete_path` is the only destructive file entry point the agent has, and it
carries `ToolTag.YOLO` — under `--yes` / `/yolo` it runs with no confirmation at
all. A delete is now a move into the desktop's own trash (freedesktop trash on
Linux, Finder trash on macOS, Recycle Bin on Windows) through `send2trash`, so
the human can put the file back with the file manager they already have. The new
`permanent=true` argument is the old behaviour, chosen on purpose.

A trashing that fails is reported, never downgraded to a deletion: the human was
told the path is recoverable, so a volume with no writable trash directory
leaves the file where it is and the error names `permanent=true` as the way to
delete it anyway. A broken install (the library is a required dependency) is
reported the same way.

Scratch paths under the OS temporary directory are still deleted for good —
nobody restores them, and a trash on the `/tmp` volume would only collect them.
The rule now lives in `tools/trash.py` (`under_system_temp`) and the temporary
workspace cleanup uses it too, instead of keeping a second copy of the check.
2026-10-21 20:18:35 +08:00
NCBM be8a0111ec core/prompting: answer a question with a notification reply
`ask` now reads its answer from the wait's source as well, which is what makes
the notification's reply field mean something: the reply typed into it is the
answer the caller gets. The readline path stays whenever the source offers no
answers, so line editing and Tab completion only go away when the human asked
for a surface that can answer — and a terminal answer still wins over one that
arrives through the notification.

The read itself is the poll-based one this module already had, so the same
"no deadline, wait for a line or an answer" rule and the same Ctrl+C handling
apply; a cancelled read raises `NonInteractiveError`, as the backend's own
cancellation does.
2026-10-21 20:02:56 +08:00
NCBM 35595d8b04 core/cli: raise the wait signal from every confirm prompt
Every place the CLI blocks on the human now announces itself: the single and
batched tool confirms, the command-denylist and `fetch_local` policies, a skill
write, a directory-access request, the `ask_user_*` questions and the
limit-continue prompt. `_setup_hooks` installs the notifier from `[notify]`, and
only for an interactive run — a one-shot run never waits on a human.

An answer that came back through the notification is read as if it had been
typed: `allow` / `deny` map onto the `y` / `n` the prompts already accept, and
the confirmation prompts switch from click's readline to the poll-based read
only when the notification actually offered answers — otherwise the terminal
experience is untouched. "Go to terminal" never reaches the prompt at all.

The stdin read behind the timed prompts now watches in 100ms slices instead of
one full-length `select`, which is what lets a notification answer end it; an
answer still loses to a human typing at the terminal. A `None` timeout waits
for whichever arrives first, and a non-pollable stdin still gives up at once.

`prompt_wait` announces only the outermost prompt of a nested read: a confirm
that asks a follow-up question while it is already on screen would otherwise
buzz the human a second time.
2026-10-21 20:01:38 +08:00
NCBM 036e0b40ad cli/notify: notify a waiting prompt through the desktop or the terminal
A prompt that has waited `grace_seconds` is now announced, and where the
platform allows it the notification carries the answer: allow / deny / review
for a confirmation, a reply field for an `ask_user_*` question. Picking
"review" withdraws the notification and keeps waiting at the terminal, and a
notification is dismissed as soon as the prompt returns — answered or not.

Two channels. The desktop one is the optional `notify` extra, run on its own
event loop in a daemon thread because a notification's callbacks only fire
while the loop that owns it is running (the library's synchronous wrapper does
not provide that). The terminal one needs nothing: it writes OSC 777 / OSC 9
to `/dev/tty` — so a redirect cannot swallow it — picking the protocol from the
environment and wrapping it for tmux / screen, falling back to a plain bell.
kitty speaks OSC 99 only, which is not implemented, so it keeps the bell.

Nothing here can break a prompt: a missing extra, a desktop that refuses, a
failed write all fall through to the next channel and never raise. Titles are
flattened and stripped of control characters, and the reason line (which can
name a command or a URL) only travels with `include_reason`.
2026-10-21 19:57:17 +08:00
NCBM 10402f5be6 core/config: add the [notify] section
`[notify]` decides what the CLI does when a prompt is waiting on the human:
`grace_seconds` (default 5s, roughly Claude Code's permission notice) before
anything is shown, `min_interval_seconds` between two notifications,
`channels` (`auto` = desktop then terminal), `actions` for the allow/deny/review
buttons, `include_reason` for the one-line reason, and `protocol` to pin the
terminal escape protocol.

The reason stays out by default because it names commands, argv and URLs, and a
notification outlives the prompt and may be mirrored to a lock screen. Unknown
keys degrade to the defaults, the way `[mcp]` does, and `reload()` re-reads the
section.

Desktop notifications need the optional `notify` extra (`desktop-notifier`),
which is the only new dependency and stays off the default install.
2026-10-21 19:53:19 +08:00
NCBM 557300ff4f core/prompting: let a host notify and answer a waiting prompt
A prompt that blocks on the human now announces itself through one process-wide
hook: `prompt_wait(kind, title, detail)` wraps `ask`, `ask_secret`, `choose` and
`confirm` and yields the answer source the host offers. `kind` names the prompt
family (`ask`, `secret`, `choose`, `confirm`, and the CLI's own `policy` /
`access` / `skills` / `limit`) so a host can pick a surface, while `title` stays
short enough to show anywhere and `detail` carries one line the caller has
already redacted. No hook installed means no behaviour change, which keeps
library hosts and the test suite exactly as they were.

The read side of the seam is `read_line_with_timeout`: an optional `source` ends
the read with the host's answer (echoed on the prompt line like a typed one) and
a `None` timeout now means "wait until a line or an answer arrives" instead of
giving up. Stdin keeps priority, so a human typing at the terminal is never
raced by a notification answer that shows up at the same moment.

`reset_prompting` clears the hook along with the output hook and the pause
factory.
2026-10-21 19:52:03 +08:00
NCBM 8ec63aa6a2 core/agent: let YOLO mode skip YOLO-tagged soft confirms
`--yes` / `/yolo on|off|once` clears the prompt of a `YOLO`-tagged tool: interpreter runs (`run_argv`, `run_argv_batch`, `open_pty`), the file mutations (overwrite / copy / move / delete) and `fetch_local`'s cleartext and mutating requests. A tool without the tag still asks, and hard path/command denylists stay untouched.

`classify_danger` now answers with a `DangerReason` — the reason text plus `yolo_skip` — so one call can refuse that skip: a command that runs through `sudo` / `su` / `doas` / `pkexec` (as the program or a wrapper) keeps its confirm whatever YOLO mode says. It never adds a reason of its own, so the program the denylist gate already asks about is not asked twice.

`prepare_batch_confirm` leaves the covered calls out, so a skipped call neither prompts on its own nor takes a slot in the round's aggregated prompt, and one escalating step keeps the whole batch in front of the human.
2026-10-21 19:10:46 +08:00
NCBM 154509b929 core/cli+config: attach files to a turn with @path mentions
Typing `@shot.png` in the line attaches that picture to the turn — no separate
command — and `@notes.txt` appends the file as text under a `--- @path ---`
header, capped like a tool result with a resumable `truncate_token`.
`@"my shot.png"` is the quoted form, and Tab completes the path after `@`
(directories end in `/`, a name with a space completes quoted).

An image is skipped, with a note naming the key to set, on a model without
`image_in`; a missing or denylisted path is a note too, and the turn still goes.
The default catalogs mark what can take a picture (`deepseek-flash` yes,
`deepseek-v4-pro` no, the OpenAI defaults yes).

`Agent.run` takes the parts, so the turn is stored with its images and shows up
as `[image]` wherever only prose is wanted.
2026-10-21 18:53:47 +08:00