Compare commits

..
57 Commits
Author SHA1 Message Date
雷电芽衣 cb4c2eaad9 update version
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
2026-09-22 18:43:42 -04:00
雷电芽衣 cfde4e91a1 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.18 workspace version 2026-09-22 18:42:49 -04:00
雷电芽衣 5082bd47ee chore(release): bump workspace version to 0.1.18 2026-09-22 18:42:49 -04:00
雷电芽衣 5d70140d62 docs(agents): the codex table is seven models 2026-09-22 18:42:49 -04:00
雷电芽衣 299c6bcfcb chore(sampler): codex User-Agent follows the 0.156.0 release 2026-09-22 18:42:49 -04:00
雷电芽衣 6be9ab0aea feat(models): gpt-6-sol and gpt-6-luna join the compiled-in codex catalog
The Codex backend (client_version 0.156.0) served seven list+supported
models on 2026-09-22: gpt-6-astra, gpt-6-sol, gpt-6-luna, then
gpt-5.6-sol/terra/luna and gpt-5.5. Sol and Luna carry context 272000,
efforts low..max, default medium. POST /codex/responses with kigi's codex
headers answers 200 and completes for both.
2026-09-22 18:42:49 -04:00
ZacharyZhang-NY 0e4e446fe4 docs(agents): record where Shift+Enter can and cannot arrive
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-09-21 19:25:20 -04:00
ZacharyZhang-NY 008b6623f6 docs(tui): correct the VTE and WezTerm notes around the Shift+Enter hint 2026-09-21 19:25:04 -04:00
ZacharyZhang-NY f8cd33d19d fix(tui): the Shift+Enter hint follows the protocol, not a brand list
`shift_enter_unavailable` kept its own list of brands and missed every
other reason the Kitty keyboard protocol gets skipped: JetBrains, screen,
tmux below 3.3 and tmux with `extended-keys off` all swallow it, yet the
prompt still advertised Shift+Enter there. It now reads
`kitty_skip_reason` directly. Two skips deliver the modifier another way
and stay available: Apple Terminal, where `route_enter` polls
CoreGraphics, and Windows Terminal, where the console key record carries
SHIFT — a bare ConHost, only optimistically refined to Windows Terminal,
keeps advertising Alt+Enter.

The old list also carried a `VTE_VERSION >= 8200` cutoff, citing VTE
0.82.0 as the first release with the protocol. It is not: at tag 0.82.0
the string "kitty" appears nowhere in `src/parser-seq.py`, `src/vte.cc`
or `src/widget.cc`, and `_vte_keymap_GDK_Return` encodes only Alt
(`ESC CR`), so Shift+Enter leaves a VTE terminal as the same bare CR as
Enter. Every VTE now advertises Alt+Enter, and `/terminal-setup` stops
telling GNOME Terminal users to upgrade to a version that would not
help. It also stops advertising Alt+Enter to WezTerm, which binds that
chord to ToggleFullScreen.
2026-09-21 19:19:21 -04:00
ZacharyZhang-NY 2e40ebb08b docs(agents): the codex table is five models, and how to refresh it 2026-09-21 18:43:46 -04:00
ZacharyZhang-NY fcb7588312 feat(models): gpt-6-astra joins the compiled-in codex catalog
The Codex backend served five `visibility=list` + `supported_in_api`
models on 2026-09-21: `gpt-6-astra` ahead of `gpt-5.6-sol/terra/luna`
and `gpt-5.5`. Astra carries context 272000 and efforts
low/medium/high/xhigh/max, default medium; the `ultra` tier it also
advertises has no kigi level and is left out. `gpt-reserve` and
`codex-auto-review` stay hidden.

Inference checked against the live backend: `POST
/backend-api/codex/responses` with `model: gpt-6-astra` and the codex
`User-Agent` kigi sends answers 200 and streams, so the model works on
the compiled-in path.
2026-09-21 18:42:22 -04:00
ZacharyZhang-NY 9ab038f573 docs(agents): rewrap the reasoning-effort paragraph 2026-09-21 18:39:46 -04:00
ZacharyZhang-NY dd5711f24f docs(agents): the codex backend does advertise an ultra tier 2026-09-21 18:39:40 -04:00
ZacharyZhang-NY a690987ea2 revert: openai-codex goes back to the compiled-in catalog
Reverts c61977c. The live listing needed no codex CLI on the user's
machine — kigi issued the request itself and `client_version` was a
compiled-in string — but the project keeps the catalog in the binary, so
the fetch path, the `OpenaiCodex` listing dialect, its parser and the
pinned client version are gone again, and the codex `User-Agent` returns
to its previous value.

The models.dev provider rename `kimi-for-coding` → `kimi-code-plan-cn`
rode in the same commit and stays: the regenerated snapshot ships under
the new id.
2026-09-21 18:36:46 -04:00
ZacharyZhang-NY 95d4f19650 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.17 workspace version 2026-09-21 15:16:47 -04:00
ZacharyZhang-NY 99e09cfaf5 chore(release): bump workspace version to 0.1.17 2026-09-21 15:16:13 -04:00
ZacharyZhang-NY 0c580afb5f chore(models): regenerate the models.dev enrichment snapshot
`xai` and `openai` carry `restrict_to_enriched`, so a live listing id the
enrichment catalog does not know is dropped. The bundled snapshot knew
neither `grok-4.7` nor `gpt-6-astra`, which left both invisible offline
and until the 24h models.dev refresh landed.

Regeneration also tripped the generator's fail-fast: models.dev renamed
the Kimi coding provider. `kimi-for-coding` is now `kimi-code-plan-cn`
(api.kimi.com — kigi's endpoint), with `kimi-code-plan-global` the
api.kimi.ai twin; the model set is unchanged. Both registry rows, the
generator target and the two snapshot lookups follow the new id, and the
cache `keep_set` guard invalidates any cache filtered under the old one.
2026-09-21 15:13:00 -04:00
ZacharyZhang-NY c61977cb70 feat(models): fetch the openai-codex catalog live
The ChatGPT Codex catalog was compiled in: four models read once from the
Codex CLI cache. The backend moved on — `gpt-6-astra` shipped, and
`gpt-5.3-codex-spark` left — so the picker showed a table that no longer
matched the account.

`GET https://chatgpt.com/backend-api/codex/models?client_version=<v>`
serves that table. Verified against a live ChatGPT account: the OAuth
Bearer alone returns 200, the query is required (400 without it), and the
backend gates the catalog on it — `0.104.0` answers with
`codex-auto-review` alone, `0.150.0` omits `gpt-6-astra`, `0.155.1`
serves the full set. `CODEX_CLIENT_VERSION` pins the current upstream
release (openai/codex rust-v0.155.1) and the codex `User-Agent` now
derives from it, so the two can never drift.

`ListingDialect::OpenaiCodex` + `parse_openai_codex_listing` keep the
`visibility == "list" && supported_in_api` models and carry the wire
context window and per-model reasoning levels; `hardcoded_catalog` and
its short-circuit in the fetch path are gone. The catalog advertises an
`ultra` tier kigi has no level for — `think_efforts_to_options` drops it
with a warning, as it does any unknown token.
2026-09-21 15:12:54 -04:00
ZacharyZhang-NY 781fb44659 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.16 dependency set
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 5s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
`cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md` (cargo-about
0.9.2) on the 0.1.16 lock: kigi crates at 0.1.16, rustls 0.23.45,
rustls-webpki 0.103.15, aws-lc-rs 1.18.1, aws-lc-sys 0.45.0. The set of
listed crates is otherwise unchanged; the large diff is block order.
2026-09-19 04:21:53 -04:00
ZacharyZhang-NY a640816c38 chore(release): bump workspace version to 0.1.16 2026-09-19 04:20:50 -04:00
ZacharyZhang-NY 256ff2528e test(config): two e2e routing tests stop racing KIGI_CODE_BASE_URL
`e2e_default_model_with_session_routes_to_proxy` and
`e2e_duplicate_model_field_both_entries_survive` assert the default
model's base URL. They were not serial and read `KIGI_CODE_BASE_URL`
through `EndpointsConfig`, while two serial tests (auth_method.rs,
models_fetch.rs) point that variable at wiremock. When the runs
overlapped, the base URL came back as the wiremock URI: the Linux suite
failed in 2 of 13 full runs. Presetting the variable fails both tests
every time, on v0.1.15 as well.

Both tests are now `#[serial]` and unset the variable for their scope,
the same cure `e2e_enterprise_endpoints_only_no_model_override` got for
the moonshot variable. With the variable preset they pass.
2026-09-19 04:16:35 -04:00
ZacharyZhang-NY 2820dd5f2b docs(agents): record the 0.1.16 upstream sync window and what it left behind
The sync record now ends at upstream `a28ee2b` (2026-09-17, ≈v1.0.35).
The items evaluated in this window and left for a later cycle are listed
with the reason each one waits, so the next sync does not triage them
again. `pager.toml` joins that list: its writer still ends in a bare
`fs::rename`.

Two stale lines go: `ultra` left the reasoning-effort vocabulary in
649c5f5, yet the file still described it as a codex-only tier.
2026-09-19 03:53:47 -04:00
ZacharyZhang-NY 11b5a47ef0 chore(deps): rustls 0.23.45 closes RUSTSEC-2026-0285
`cargo deny check advisories` failed on rustls 0.23.42: TLS 1.3 handshake
messages were accepted across encryption-level boundaries. `cargo update
-p rustls` moves rustls to 0.23.45 and, with it, rustls-webpki to
0.103.15, aws-lc-rs to 1.18.1 and aws-lc-sys to 0.45.0.

The same update also re-picked six unrelated edges among versions
already in the lock (errno, quinn-udp, rustix, rustls-platform-verifier
and tempfile from windows-sys 0.52.0 to 0.59.0; tempfile from getrandom
0.4.3 to 0.3.4). Those edges are restored, so the diff is the four
packages only: version and checksum, sixteen lines. `cargo metadata
--locked` accepts the lock.

Advisories pass. `cargo clippy --workspace --all-targets --locked` is
clean on Windows.
2026-09-19 03:45:52 -04:00
ZacharyZhang-NY bc93b540cd fix(clipboard): the macOS image probe writes into a private scratch dir
The osascript fallback hands the pasted raster to kigi through a file.
Every kigi process used the same three paths in the shared temp dir
(`kigi-clipboard-probe.{png,tiff,jpg}`). Two sessions pasting at once, or
a stale file from an earlier probe, could swap the file between the
AppleScript's write and kigi's read: the wrong image went to the model.

`ProbeTemps` owns a fresh 0700 directory per probe and removes it on
drop, so the hand-written removals are gone. The script builder, the
raster reader, and the read flow move out of the cfg(macos) module into
`clipboard/osascript_probe.rs`, which compiles under test on every
platform; the flow takes the script runner as a closure. The script text
with a scratch dir is byte-identical to the old builder's (checked once
against the old function, then the copy was dropped).

A file paste needs no scratch file. When the directory cannot be made,
the probe warns, sends the file-URL half of the script only, and answers
file URLs as before; with no file URL either, the scratch error surfaces
instead of an empty board. `get_image` needs the file and fails fast.

Tests (all platforms): probes never share a path, the directory goes
away with the probe, mode 0700 on unix (Linux under WSL), the reported
class is read from this probe's file, file URLs survive a missing
scratch dir, and no dir plus no file URL is an error. The remaining
cfg(macos) callers were type-checked in a stubbed scratch crate; the
warm-cache workflow builds both darwin targets before the tag.

Ported from upstream (grok-build shared/clipboard.rs ProbeTemps and
osascript_attachments, 1.0.33).
2026-09-19 03:33:02 -04:00
ZacharyZhang-NY 0189009822 fix(config): a symlinked ~/.kigi/config.toml survives a save
Every config.toml writer wrote a tmp beside the path and renamed it over
the path. With config.toml symlinked into a dotfiles repo, the rename
replaced the link with a regular file: the repo copy went stale and the
link was gone.

`config_write_dest` resolves the destination before a writer names its
tmp. For the user's own config path a symlink resolves to its referent,
so the tmp sits on the referent's filesystem and the rename stays atomic.
A dangling link is an error, not a clobber. All eight writers call it:
`save_config`, the four MCP writers, and the three import writers.

Only the user's config path is followed, and only while
`user_kigi_home()` resolves. `kigi mcp add --scope project` and the
Claude project import write `.kigi/config.toml` inside the repo; a
hostile repo could ship that file as a symlink to any file the user
owns. With no home, `kigi_home()` falls back to `./.kigi`, which is that
same project tree, so nothing is followed then. Untrusted paths come
back unchanged and the link itself is replaced, as before. AGENTS.md
records the rule.

Tests (unix; run on Linux under WSL): helper cases including the no-home
case, a project-scope save that spares the link's target, and an
end-to-end save under KIGI_SHARE_DIR. The end-to-end test fails with
following disabled. Windows symlinks were not exercised: the host lacks
the privilege.

Ported from upstream (grok-build config/fs_atomic.rs LeafSymlink::Follow,
1.0.35): the rule, not the 1375-line module.
2026-09-19 03:15:01 -04:00
ZacharyZhang-NY 206711bed5 fix(skills): a skill body stops at the 25k-token read cap
`read_file` exempted SKILL.md from every limit, and both injection paths
(`/skill` expansion and the `skill` tool) pasted the whole body. A 220 KB
skill put 55k tokens into the context on each call.

`cap_skill_body` cuts an over-cap body at the last line boundary that
fits (a char boundary when the body is one long line) and appends a note
that points at offset and limit. It runs before `apply_substitutions`, so
the arguments land after the cut. Both injection paths call it and log
the cut.

`read_file` returns a SKILL.md whole only while it fits the cap. Over the
cap it reads like any other file: offset and limit apply, and a window
that is still too large answers FileTooLarge. Without that the note would
send the model to a read that ignores its range.

`skill_file_skips_token_limit` pinned the exemption; it gives way to
`skill_file_over_cap_returns_file_too_large` and
`skill_file_over_cap_honors_offset_and_limit`, as upstream did.

Ported from upstream (grok-build tools skills/skill.rs cap_skill_body and
read_file skill_full, 1.0.33). Upstream's `max_output_bytes` read budget
in the same hunk is a feature and stays out.
2026-09-19 02:31:24 -04:00
ZacharyZhang-NY 443de79e2f fix(textarea): Up and Down land on the soft-wrapped row they target
A soft-wrapped row and the next row share a byte: the row's exclusive end
is the next row's start. Up or Down with a goal column past the target
row's width put the cursor on that byte, so it drew on the wrong row. Up
made no progress and Down skipped a row.

`row_with_end` tells a soft wrap from a hard break (byte-adjacent rows
are soft). `move_to_display_col_on_line` takes the row and its `RowEnd`
and clamps a soft row to its last grapheme, or to the start of an element
that closes the row. A hard-break row still reaches its newline.
`clamp_to_line` now cuts on graphemes, not chars, so a ZWJ cluster stays
whole.

Kigi differs from upstream's premise that elements wrap whole: with no
display override in the buffer, `wrapped_lines` uses plain textwrap and
an element can span rows. Snapping to such an element's start would
leave the target row (Down from byte 0 stuck at 0), so an element that
began on an earlier row keeps the boundary `display_col_to_buffer_pos`
chose, as before this change.

`wrapped_navigation_with_zwj_graphemes` asserted the bug: Up from the
end stayed on the last row (byte 22). It now expects row 1's cluster
start (byte 11), as upstream does.

Tests: upstream's four cases, the row-spanning element, and a seeded
property test (500 cases): each Up or Down moves exactly one row. The
property test fails with the clamp disabled.

Ported from upstream (grok-build ratatui-textarea/textarea.rs, 1.0.28).
2026-09-19 02:17:19 -04:00
ZacharyZhang-NY be0983237e fix(mcp): an abandoned tool call tells the server to stop
`try_call_tool` wrapped `call_tool` in a bare `tokio::time::timeout`.
On a timeout or a cancelled turn the future dropped and the server kept
working: no `notifications/cancelled` ever left the client.

`call_tool_cancel_aware` sends the request through
`send_cancellable_request` and holds a guard armed with the request id.
A settled call disarms it. A dropped future (cancelled turn) spawns the
cancel from the guard's drop. A timeout sends the cancel inline, before
returning: the HTTP timeout arm resets the transport next, and dropping
the last service handle killed a spawned cancel before it left.

The inline send waits 500 ms at most. rmcp 2.2.0 awaits its own
timeout-cancel without a bound, and its streamable-HTTP worker awaits
each POST in turn, so against a JSON-mode server the cancel queues
behind the hung call: with rmcp's timeout path the call returned after
3.0 s against a 1 s budget. That server class cannot receive the cancel
until rmcp 3.x; the budget elapses, a warn is logged, and AGENTS.md
records the limitation. stdio and SSE-mode servers receive it.

Tests: dropped call, timed-out call followed by a service drop, settled
call; the HTTP outer-timeout test now asserts the return time.

Ported from upstream (grok-build mcp/servers.rs call_tool_cancel_aware,
1.0.33); upstream leans on rmcp 3.2's timeout path.
2026-09-19 02:07:30 -04:00
ZacharyZhang-NY 3ccb6e5e91 fix(config): reasoning_effort accepts mixed case
`reasoning_effort = "High"` in config.toml failed the whole parse: the
derived Deserialize matched lowercase variants only, while FromStr
lowercased its input. Deserialize now routes through FromStr, so both
paths share one vocabulary. Unknown tokens and non-strings still error.

Ported from upstream (grok-build sampling-types/types.rs, 1.0.35).
2026-09-19 01:51:55 -04:00
雷电芽衣 0fb971e9aa chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.15 dependency set
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
`cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md` on the 0.1.15
lock: kigi crates at 0.1.15, chacha20 0.10.2.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:38:08 -04:00
雷电芽衣 a2ad08fb52 chore(release): bump workspace version to 0.1.15
Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:37:16 -04:00
雷电芽衣 b9ca80e801 docs(agents): record the 0.1.15 upstream sync window and its known limitations
Sync record now ends at upstream `3794978` (2026-09-09, ≈v1.0.24). Lists
the two limitations the sync left: the Windows frame writer still prints
through std's stderr lock, and the bwrap builder re-stats /data.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:36:58 -04:00
雷电芽衣 5f249ba6ca chore(deps): chacha20 0.10.2 replaces the yanked 0.10.1
`cargo deny check advisories` failed on the yanked crate (via rand 0.10 →
sigstore-tsa → nono). `cargo update -p chacha20`; no other lock change.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:36:58 -04:00
雷电芽衣 d331b0b2a3 fix(goal-classifier): git diff headers keep a/ b/ prefixes under any host gitconfig
`extract_changed_files` parses `diff --git a/<old> b/<new>` headers.
A host `diff.mnemonicprefix=true` or `diff.noprefix=true` rewrote them
(`c/ w/`, or none), so every tracked change vanished from `changed_files`
and the two evidence git-diff tests failed on such hosts. Both capture
paths now pass `--src-prefix=a/ --dst-prefix=b/`, which override both
config keys. Regression test sets each key in the temp repo.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:36:58 -04:00
雷电芽衣 09867915ef fix(sandbox): the devbox /data write-deny makes bubblewrap mandatory
A devbox-based profile on a host with /data warned and fell back to
Landlock when bubblewrap was missing or failed to exec, and Landlock
grants the workspace under /data. requires_data_write_deny (devbox
inheritance and an existing /data; a stat error other than NotFound
counts as present) now joins requires_read_deny in the fail-closed
startup decision, and the /data ro-bind is added only under that
predicate, so a missing /data no longer makes bwrap fail to exec.

Ported from upstream (grok-build sandbox/lib.rs requires_data_write_deny).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:17:52 -04:00
雷电芽衣 edce333620 fix(subagent): project-agent mcpServers follow folder trust; agent names are one path component
A project-scoped agent definition from an untrusted repository could
declare inline stdio mcpServers and the child session started that
command, while inline hooks on the same definition were already gated on
folder trust. The mcpServers list now uses the same gate.

By-name agent lookup joined the requested name onto each searched
directory, so a name carrying a separator or ".." resolved a file outside
that directory under its trust scope; both lookup entry points now reject
any name that is not a single path component.

Ported from upstream (grok-build subagent/handle_request.rs mcpServers
gate), extended with the name guard.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:04:46 -04:00
雷电芽衣 d1c29a576c fix(compaction): failures carry the real error, with endpoint credentials redacted
The pager rendered "Compaction failed." with the error dropped, and the
shell's unsuppressed auto-compaction failure sent an empty error. Both
now carry the decoded first line of the failure (retry guidance plus the
detail), decoded from the ACP data rather than its JSON-escaped Display,
propagated unchanged through the compact extension, capped for a banner.

Endpoint credentials never reach the banner: the sampler redacts the
parsed endpoint (userinfo, sensitive query values, fragment via the
repository sanitizer) in every 401 message and the appended request URL,
and the shell redacts every URL in the error text before line selection,
including URLs whose password holds whitespace or that follow a
multi-byte delimiter.

Ported from upstream 1.0.13 (grok-build dispatch/prompt.rs,
session/compaction.rs), extended with the redaction.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 12:12:00 -04:00
雷电芽衣 17d4ba08f9 fix(exit): diagnostics after the terminal may be gone are best-effort writes
With fd 2 dead (a closed pane, an exited pipe reader) every write fails,
eprintln! panics on that failure, and under panic = "abort" the process
dies by SIGABRT with a crash report on the next launch instead of its
exit code. Exit reports, headless plain-format diagnostics, update-exit,
setup-result and leader-management messages now write through
best_effort_stderr::eprint_line: one attempt, failure reported, never a
panic.

Ported from upstream 1.0.19 (grok-build best_effort_stderr.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 10:39:20 -04:00
雷电芽衣 901203c2b2 fix(tui): pasted U+2028 and U+2029 become line breaks
str::lines() splits on neither separator, so a paste carrying them counted
as one line and the invisible characters broke the textarea's width model.
Bare \r, U+2028 and U+2029 now normalize to \n on every paste route (the
prompt, the inline paste key and the previous-message editor); dropped
file paths keep their bytes verbatim because a separator in a filename is
payload.

Ported from upstream 1.0.18 (grok-build prompt_widget normalize_line_breaks).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 10:16:22 -04:00
雷电芽衣 d8609267a1 fix(images): resize over-cap attachments even when the re-encode saves no bytes
An attachment over the side or area caps whose re-encode did not shrink
the byte count was returned unchanged at its original dimensions, so the
provider still received an over-cap image. The byte-saving short-circuit
now applies only within the caps; a resize that would fall under the
vision minimums keeps the original on the existing fallback path.

Ported from upstream 1.0.13 (grok-build session/image_normalize.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:53:11 -04:00
雷电芽衣 a7b8438881 fix(tui): refresh the context bar when auto-compaction starts
AutoCompactStarted carries the count the trigger fired on, but only
AutoCompactCompleted refreshed the context bar, so the "N% full" banner
sat next to a stale pre-turn number until the next stream update. Both
lifecycle updates now refresh the bar, on the root view and on subagent
views.

Ported from upstream 1.0.12 (grok-build acp_handler/session_notification.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:35:27 -04:00
雷电芽衣 584d0fb77e fix(tui): /compact <instructions> reach the compaction request
The queued /compact command drained into Effect::Compact with only the
session id, so the instructions typed after the command never reached the
shell's compaction prompt (which already accepts userContext). The effect
carries the parsed instructions, trimmed so an edited queue row with
leading whitespace still counts.

Ported from upstream 1.0.22 (grok-build dispatch/queue.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:23:39 -04:00
雷电芽衣 4512df79e6 fix(tui): terminal escapes ride the writer queue and teardown is bounded
A terminal that stopped draining the pty parked the writer thread inside
its tty write while it held the stderr lock; the next inline stderr write
from the event loop (mouse toggle, notification, kitty clear) deadlocked
the loop, and /quit hung on the unbounded writer join and the teardown
writes.

EscapeWriter queues out-of-band escapes on the frame writer: the mouse
capture toggle, notifications (title, progress, protocol), the auth
cursor, gboom keyboard flags, the wrap clipboard request, and the kitty
clears (queued per agent view and merged into the frame's post-flush,
nested subagent views included). The $EDITOR/$PAGER handoff aborts when
the writer does not drain. WriterThread::join_within and the teardown
run under a 2 s grace; after a stall fd 2 is silenced, the exit hints,
relaunch and update prints are skipped, and the panic hook keeps only its
log report.

Ported from upstream 1.0.19 (grok-build EscapeWriter, WriterJoin,
run_bounded_teardown), extended to the remaining inline writes.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:08:09 -04:00
雷电芽衣 91f14112bf fix(subagent): bound the completion-path awaits so teardown reaches Shutdown
A completed child parked forever behind a parent that never serviced its
command channel, or behind its own frozen chat-state actor, leaking the
child session thread, fs watchers and fds. Every await in the completion
path that rides another actor is now bounded: child chat-state reads
degrade to their fallbacks after 10 s, the usage-fold and not-applied acks
give up after 30 s (an unacked mark falls back to the coordinator's sticky
mark), list_tasks and reparent_notifications are bounded, and the
post-Shutdown worktree snapshot is bounded while removal is awaited in
full so no detached deletion can race a resume.

Ported from upstream 1.0.14 (grok-build subagent/handle_request.rs
PARENT_ACK_TIMEOUT / child_actor_query), reshaped without the drain-budget
const assert.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:57:20 -04:00
雷电芽衣 fc7113ba3c fix(chat-state): reasoning estimate and calibration reseed on every rewrite
A Reasoning item counted its text and its encrypted blob as two separate
payloads, inflating the estimate for retained reasoning; the estimate now
takes the larger of the two (base64 runs 4/3 over the raw bytes).

Only compaction carried the provider-confirmed ratio into the reseeded
total; a rewind or a non-compaction replace sprang the context meter back
to the raw estimate. reseed_total_tokens applies the ratio (capped at the
pre-rewrite total) to every rewrite, and the truncate path uses it too.

Ported from upstream 1.0.1x (grok-build xai-chat-state actor/state.rs,
mutations.rs, queries.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:24:07 -04:00
雷电芽衣 00a5e318bb fix(config): an explicit [model.X] api_backend survives same-slug propagation
The slug-match step treated an entry whose api_backend equals the default
as unset and overwrote it with a same-slug sibling's backend, so
`api_backend = "chat_completions"` silently became messages/responses.
Config keys whose override sets api_backend are recorded and skipped by
the donor step; an unset backend still inherits.

Ported from upstream 1.0.22 (grok-build agent/config.rs
explicit_api_backend_keys).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:13:47 -04:00
雷电芽衣 017a570d7d fix(sampler): a max_tokens stop after complete tool calls runs them
The Messages stream failed every max_tokens stop with MaxTokensTruncation,
so a response that had already emitted complete tool calls before the
output budget ran out lost those calls and failed the turn. The stream
now completes with StopReason::ToolCalls when every collected call's
arguments parse (or are empty, the zero-argument convention); a cut call
or a text-only truncation still fails so compact-on-error keeps working.
The ChatCompletions and Responses streams already behaved this way.

Ported from upstream 1.0.1x LengthPolicy::CompleteToolCalls, reshaped
into the stream layer because Kigi has no caller that chooses a policy.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:02:05 -04:00
雷电芽衣 63d26d4a3e fix(persistence): FlushAndAck is a durability barrier that reports lost writes
The persistence actor acked FlushAndAck unconditionally after writing its
pending merged notification: nothing was fsynced and every storage failure
was only warn-logged, so persist_ack, the reconnect flush and history
repair treated lost writes as persisted.

The barrier now writes the pending notification, reports the first storage
failure latched since the last barrier (every actor arm latches), and
fsyncs the whole session directory tree (files, directories, deletions).
Session directories are created durably (init and fork copy), and the
shared summary writer syncs its staged bytes before publishing and the
parent afterwards, so a concurrent /rename cannot publish unsynced bytes
over a synced summary.json. FlushComplete and the persist_ack, history
repair and end-of-turn callers propagate the io::Result; CopyFile fails on
a sync error but keeps the latch for the barrier.

Ported from upstream 1.0.1x (grok-build persistence.rs pending_write_error
+ durable storage helpers), reshaped without the dirty-file set.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 06:51:27 -04:00
雷电芽衣 5f7416f3ef fix(permissions): native path deny/ask rules apply to the file the tool opens
Edit/Read/Grep rules matched only the raw argument string, so a workspace
symlink (or a quoted, `~`, `./`, `..`, or absolute spelling) reached a
denied file. The policy now resolves the path exactly as the tools do
(sanitize, expand `~`, display-cwd remap, join to the requesting session's
cwd), follows symlinks parent-first, and re-checks deny/ask rules on the
opened path and its target as absolute, `./`-relative and relative
spellings. Escalation only: no allow is granted through a link. A link
that cannot be followed forces a prompt that survives YOLO, auto mode and
the session edit grant, with event reason `unresolved_link`.

Permission requests now carry the requesting tool's cwd and display cwd
so a child worktree session sharing its parent's handle is checked where
it runs.

Ported from upstream 1.0.14 (grok-build permission/policy.rs symlink
re-check), reshaped for Kigi's lexical rule matcher.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 05:48:06 -04:00
雷电芽衣 fb3fe89d33 fix(permissions): rg exec flags cannot ride an allowlist, and argv is decoded
Port of upstream grok-build 3794978 (snap6 "Reject ripgrep --hostname-bin
on always-safe and Auto routine paths"), widened where review showed the
same hole one layer down.

- `rg --pre` / `--hostname-bin` execute a program per file. The safe
  list refused only `--pre`, and the Auto-mode routine classifier had
  no rg flag check at all. One shared `rg_has_unsafe_flag` now refuses
  both flags (bare or `=VALUE`) on a lowercased-basename head, scanning
  every argument: rg's grammar (`--replace -- --pre`, `-ne --`) makes a
  partial arity model bypassable, so a literal search for the flag name
  costs a prompt instead of the auto-allow.
- The shell splitter handed allowlists undecoded source: a concatenation
  (`--host""name-bin`, `''--hostname-bin`, `--p're'`) kept its quotes,
  and double-quoted escapes (`"--host\<newline>name-bin"`) survived.
  `PlainCommand` words are now the decoded argv (bash double-quote
  rules, whole-node text minus the quotes so literal newlines and the
  whitespace tree-sitter folds into the closing quote survive), with a
  per-word `expandable` flag for unquoted glob/brace/tilde/backslash
  syntax the shell would still expand (`--{pre,pre}`, a glob that can
  match a file named `--pre`); the rg guard treats those as unsafe.
- Every prefix-based decision matches per argv element through one
  matcher (built-in safe list, always-safe list, dangerous list, Auto
  routine prefixes): a decoded word with inner whitespace (`rg    `,
  `rg /../payload`, one word spelled `git status`) can no longer pose
  as a prefix. Saved grants match token for word (a grant saved from a
  spaced argument prompts again). Saved denies keep the pre-0.1.15
  check verbatim on a `legacy_words` spelling (raw content and raw
  concatenations, wrappers peeled independently, space-boundary prefix)
  so no deny narrows on upgrade.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 04:47:21 -04:00
雷电芽衣 aa9a5dbe72 fix(auto-mode): git checkout/switch/stash no longer ride a blanket prefix
Port of upstream grok-build 7581004 (1.0.22 "Auto mode no longer
instantly runs destructive git checkout -- commands"), routine_git.rs.

`ROUTINE_PREFIXES` auto-allowed `git checkout`, `git switch` and
`git stash` wholesale, so Auto mode ran `git checkout -- <file>` (a
working-tree discard) and `git stash drop`/`clear` without review.
Those verbs now go through a fail-closed operand classifier: `switch`
is routine with one ref operand and benign flags; `stash` only for
push/save/pop/apply/list/show/branch; `checkout` additionally needs a
branch-mode flag (`-b`, `-B`, `--orphan`, `--detach`, `-t`) anywhere in
the argument list before an operand counts, because a bare
`git checkout <name>` — `-` included — restores a tracked path when no
such ref resolves (a deliberate deviation from upstream, which allowed
`git checkout main` outright). Path-like operands, pathspec flags and
`--` fail closed. What is not routine goes to the model side-query, not
to a hard block.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 02:36:14 -04:00
雷电芽衣 7a60cd42ac fix(trust): gate directory-typed project hook sources
Port of upstream grok-build 7581004 (1.0.22 window) folder_trust.rs.

`collect_repo_config_kinds` gated `.kigi/hooks` with `is_dir()` and
`.cursor/hooks.json` with `is_file()`, and `.claude/settings*.json` via
the settings-file scan, while the hook loader (`path_to_source`) takes
a directory at any of the four project hook paths as a hook source and
parses its JSON children. A clone carrying `.cursor/hooks.json/evil.json`
or `.claude/settings.json/evil.json` and nothing else resolved as
trusted through the no-config branch and ran its SessionStart hook.

The gate now treats any present path (file, directory, dangling
symlink) or an unreadable one at the four sources
`discover_hook_source_paths` loads from the git root as a hit.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 02:17:25 -04:00
雷电芽衣 97c934a647 fix(sandbox): strict profile no longer lets a child rewrite ~/.kigi
Port of upstream grok-build 1.0.14 ("--sandbox strict now restricts
writes to ~/.grok/sessions only"), shaped for Kigi.

The strict profile granted the whole ~/.kigi read-write, so a sandboxed
command could plant a global SessionStart hook or flip
`[sandbox].profile` in config.toml for the next launch. Landlock cannot
carve a child out of a write grant, so the home is now read-only
except an explicit allowlist of state subtrees (run, sessions,
worktrees, worktree_pool, tasks, memory, logs, debug, crash, memtrace,
trace-exports); config.toml, auth.json, sandbox.toml, hooks/, bin/ and
vendor/ stay read-only. A symlinked state dir under the home is
followed only to the default ~/.kigi/sessions.

What the process itself writes there after enforcement moves or is
minted first:
- kigi-shell `apply_sandbox` mints the device id, extracts the bundled
  rg and the bundled skills BEFORE enforcement for every profile that
  restricts home writes (`restricts_home_writes`, custom profiles
  extending strict included).
- The sandbox audit log lives at ~/.kigi/sessions/sandbox-events.jsonl.
- Leader lock/socket default to ~/.kigi/run/, the leader stderr log to
  ~/.kigi/logs/leader.log; discovery scans run/ and the legacy root,
  and the default target prefers the current socket.
Root-level caches (version.json, agent_id, slash-mru.json, pager.toml,
claude_import_state.json) degrade best-effort under strict; documented.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 02:08:42 -04:00
雷电芽衣 cadc46f5dc fix(sandbox): close io_uring and sendmmsg holes in the child network filter
Port of upstream grok-build 9684fa3 (1.0.11 window) `child_net.rs`.

The per-child seccomp filter matched seven socket syscalls by number and
allowed everything else, so a restricted child could still reach the
network through `sendmmsg` or an io_uring submission queue, and a
syscall issued through another arch table (x32 on x86_64) never matched
the deny list at all.

- Gate on the audit arch before matching `nr` and reject x32 numbers on
  x86_64 (foreign-table syscalls now get EPERM instead of passing).
- Add `sendmmsg`, `io_uring_setup`, `io_uring_enter`, `io_uring_register`.
- Build the program once (`prebuilt_child_network_filter`) and hand the
  slice to the `pre_exec` installer; the old code allocated the Vec after
  `fork`, which is not async-signal-safe. All three callers updated.
- Tests evaluate the BPF program over synthetic `seccomp_data`.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 01:19:36 -04:00
雷电芽衣 b957e12370 fix(tui): repaint the header context window on a /model switch
After `/model`, the header kept the previous model's window ("15K /
1.0M" on a 262K model) until the next turn or a restart. The pager
caches the window inside its context snapshot (streaming `totalTokens`
updates refresh it from the catalog) and nothing refreshed the cache
when the session's model changed. Comparing cached numbers against
catalog windows cannot tell a catalog-derived total from a shell
effective limit (KIGI_DEBUG_CONTEXT_WINDOW, header upgrades), so the
shell, which installs the effective window, now reports it:

- kigi-shell: `handle_set_session_model` returns the installed window;
  `model_switch::apply` stamps it as `totalContextTokens` in the
  `session/set_model` response meta and carries it on the
  `ModelChanged` broadcast (`context_window`, serde-default: old pagers
  ignore it, old leaders omit it). The response meta previously
  serialized the actor's inner `Result` and reported success on an
  actor failure; that error now propagates.
- kigi-tui: `Effect::SwitchModel` reads the meta; `SwitchModelComplete`
  carries `Ok(Option<u64>)`; the completion handler and the follower
  `ModelChanged` arm call `AgentView::apply_context_window`, which
  re-derives the snapshot (pct, free tokens) and reports whether it
  changed so a follower redraws on a window-only change.
- Tests: the bare `/model <name>` dispatch path asserts the snapshot and
  the drawn header; follower with/without the field; same-model new
  window redraw; actor returns the window; serde round trip.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 00:15:14 -04:00
雷电芽衣 ff92a00fea chore(fmt): reflow three link constants rustfmt rejects
f7a4cb6 repointed the GitHub links without running cargo fmt; the
fmt gate has been red since.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-12 23:17:44 -04:00
ZacharyZhang-NY f7a4cb64ab Return the forge to GitHub: releases, self-update, links, CI
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
The GitHub account is restored, so github.com/ZacharyZhang-NY/Kigi-CLI is
the canonical repo again. Reverse the 2026-08 Gitea migration:

- .github/workflows/{release,warm-cache}.yml: restore the GitHub-hosted
  pipeline dropped in 3e1652c, unchanged — hosted runners, rust-cache,
  softprops release with every asset attached atomically.
- .gitea/workflows/release.yml: retire the Gitea edition (the runners are
  being deregistered; git.zacharyzhang.com keeps the v0.1.12/v0.1.13
  releases for old clients).
- kigi-env: update_base_url now defaults to the GitHub Releases API. The
  updater already speaks GitHub wire format and sends a User-Agent; v0.1.14
  is published to BOTH forges with identical assets so pre-0.1.14 installs
  polling Gitea self-update onto the GitHub channel.
- install.sh / install.ps1: resolve releases from api.github.com.
- feedback, /docs, SECURITY.md, frontend worker + site: repo, issues and
  raw-file links point at github.com / raw.githubusercontent.com.
- docs/RELEASE.md: GitHub edition of the checklist; Gitea runner sections
  retired.
- version: 0.1.13 -> 0.1.14 (the cross-forge transition release).

Claude-Session: https://claude.ai/code/session_01A6RcwHhhsFYYrNdQB4ASuP
2026-09-01 01:05:22 -04:00
145 changed files with 11440 additions and 6905 deletions

No files matched your search

-375
View File
@@ -1,375 +0,0 @@
name: Release
# PRD F8, Gitea edition. On a v* tag push, build the single-file kigi binary
# for the five supported targets, package kigi-<version>-<target-triple>.
# {tar.gz|zip} (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish
# them as a Gitea Release alongside SHA256SUMS. install.sh, install.ps1 and
# auto_update::release_asset_name() all resolve these exact names — keep the
# naming in lockstep with them.
#
# RUNNERS. Gitea has no hosted runners; every label below must be a runner you
# registered yourself (docs/RELEASE.md has the register commands):
#
# ubuntu-latest Linux x86_64, container mode — the label the Dokploy box's
# runner already advertises. Builds both Linux targets;
# aarch64 is a cross-compile, so this leg installs the
# aarch64 GNU toolchain. Because the image is a bare
# runner-image, the Linux legs provision their own apt
# packages and rustup (see the provision steps below).
# Registering a dedicated `linux-x86_64:docker://...` runner
# instead is fine — swap the three labels back if you do.
# macos-arm64 Apple Silicon Mac, host mode. Builds both Darwin targets;
# x86_64 is a cross-compile against the same SDK. macOS
# CANNOT be built anywhere else — Apple's SDK licence
# restricts it to Apple hardware.
# windows-x86_64 Windows x86_64, host mode, with VS 2022 Build Tools
# (C++ workload). Needed because the tree links ring,
# mimalloc, zstd-sys and libz-sys against a static MSVC CRT
# (-C target-feature=+crt-static).
#
# SHELLS. Every step names its shell explicitly and the Windows leg never uses
# bash. On a normal Windows box `bash` resolves to WSL (not Git Bash), and
# `pwsh` (PowerShell 7) is usually absent — so Windows steps use `powershell`,
# which is the built-in 5.1. Keep those steps 5.1-compatible: no `&&`, no
# ternary, no null-coalescing.
#
# Note: because this file exists, Gitea ignores .github/workflows entirely.
#
# PUBLISH ORDER. The release is created as a draft and only made public once
# SHA256SUMS is attached, so `releases/latest` never hands the installer a
# release whose checksum manifest is still missing.
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
# Matches the version pinned by the dotslash launcher in bin/protoc. That
# launcher is not used here: it has no Windows entry and needs a `cargo
# install dotslash` first, so every platform fetches protoc directly.
PROTOC_VERSION: "29.3"
jobs:
prepare:
name: prepare draft release
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Create or reuse draft release
id: draft
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
# The runner-image is bare; jq and curl are both used below.
if ! command -v jq >/dev/null || ! command -v curl >/dev/null; then
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
fi
# Idempotent so a re-run of a failed tag build reuses the same draft
# instead of erroring on a duplicate tag_name.
existing="$(curl -sS -H "Authorization: token $TOKEN" \
"$API/releases/tags/$GITHUB_REF_NAME")"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
prerelease=false
case "$GITHUB_REF_NAME" in *-*) prerelease=true ;; esac
created="$(curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\",\"draft\":true,\"prerelease\":$prerelease}" \
"$API/releases")"
release_id="$(printf '%s' "$created" | jq -r '.id')"
echo "Created draft release $release_id (prerelease=$prerelease)"
else
echo "Reusing release $release_id for $GITHUB_REF_NAME"
fi
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
build:
name: build (${{ matrix.target }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
cross_gnu: true
- target: aarch64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-pc-windows-msvc
runner: windows-x86_64
protoc: win64
ext: zip
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
# ── linux container provisioning ──────────────────────────────────────
# The Linux legs run in a bare gitea runner-image, which ships git and
# node for the JavaScript actions but no compiler, no unzip (protoc) and
# no rustup. Host-mode macOS and Windows already have their toolchains
# installed, so this step is Linux-only.
- name: Provision Linux build deps
if: matrix.linux
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends \
build-essential pkg-config ca-certificates curl unzip tar jq
# ── protoc ────────────────────────────────────────────────────────────
- name: Install protoc (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip"
curl -fsSL -o protoc.zip "$url"
rm -rf "$HOME/protoc"
unzip -q -o protoc.zip -d "$HOME/protoc"
chmod +x "$HOME/protoc/bin/protoc"
echo "$HOME/protoc/bin" >> "$GITHUB_PATH"
- name: Install protoc (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
$dest = Join-Path $env:USERPROFILE "protoc"
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
Expand-Archive protoc.zip -DestinationPath $dest -Force
Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin")
# ── toolchain ─────────────────────────────────────────────────────────
# rust-toolchain.toml pins the channel (1.97.0) and preinstalls both
# Linux targets; `rustup show` materialises exactly that.
- name: Install toolchain (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
# No-op on the macOS host runner, which has rustup installed already.
# `--default-toolchain none` leaves the channel choice to
# rust-toolchain.toml, which `rustup show` then materialises.
if ! command -v rustup >/dev/null; then
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal --default-toolchain none
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup show
rustup target add ${{ matrix.target }}
- name: Install toolchain (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
rustup show
rustup target add ${{ matrix.target }}
# ring, mimalloc, zstd-sys and libz-sys are all cc-rs builds, so the
# cross target needs its own C toolchain — cc-rs picks these up from the
# target-suffixed vars.
- name: Install aarch64 cross toolchain
if: matrix.cross_gnu
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
{
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc"
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc"
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++"
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar"
} >> "$GITHUB_ENV"
# ── build ─────────────────────────────────────────────────────────────
- name: Build kigi (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Build kigi (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# ── package, upload, checksum ─────────────────────────────────────────
# Archives go straight to the release rather than through the artifact
# store: they are large, and the release is the only place install.sh
# can read them from anyway. Only the checksum line travels as an
# artifact, for the publish job to concatenate.
- name: Package and upload (unix)
if: matrix.ext == 'tar.gz'
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@${archive}" \
"$API/releases/$RELEASE_ID/assets?name=${archive}" > /dev/null
echo "Uploaded $archive"
if command -v sha256sum >/dev/null; then
sha256sum "$archive" > "checksum-${{ matrix.target }}.txt"
else
shasum -a 256 "$archive" > "checksum-${{ matrix.target }}.txt"
fi
cat "checksum-${{ matrix.target }}.txt"
- name: Package and upload (windows)
if: matrix.ext == 'zip'
shell: powershell
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
$ErrorActionPreference = "Stop"
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = Join-Path $env:RUNNER_TEMP "staging"
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
New-Item -ItemType Directory -Force -Path $staging | Out-Null
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force
# System32\curl.exe, not PowerShell's `curl` alias for
# Invoke-WebRequest, which cannot do multipart uploads.
$api = "$env:API/releases/$env:RELEASE_ID/assets?name=$archive"
& "$env:SystemRoot\System32\curl.exe" -fsS -X POST `
-H "Authorization: token $env:TOKEN" `
-F "attachment=@$archive" $api
if ($LASTEXITCODE -ne 0) { throw "asset upload failed ($LASTEXITCODE)" }
Write-Host "Uploaded $archive"
# sha256sum's exact format, so the publish job can just concatenate.
# ASCII keeps the BOM out — a BOM would corrupt the first hash.
$hash = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant()
"$hash $archive" | Out-File -FilePath "checksum-${{ matrix.target }}.txt" -Encoding ascii
Get-Content "checksum-${{ matrix.target }}.txt"
- uses: actions/upload-artifact@v3
with:
name: checksum-${{ matrix.target }}
path: checksum-${{ matrix.target }}.txt
if-no-files-found: error
publish:
name: publish release
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
path: checksums
- name: Assemble SHA256SUMS
shell: bash
run: |
set -eu
# download-artifact v3 nests each artifact in its own directory. The
# Windows line arrives CRLF-terminated; strip CR so every line is LF
# for install.sh's `read -r hash name`.
find checksums -name 'checksum-*.txt' -exec cat {} + \
| tr -d '\r' | grep -v '^$' | sort -k2 > SHA256SUMS
lines="$(wc -l < SHA256SUMS)"
if [ "$lines" -ne 5 ]; then
echo "expected 5 checksum lines, got $lines" >&2
cat SHA256SUMS >&2
exit 1
fi
cat SHA256SUMS
- name: Attach SHA256SUMS and publish
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@SHA256SUMS" \
"$API/releases/$RELEASE_ID/assets?name=SHA256SUMS" > /dev/null
# Flip the draft public only now that every asset install.sh looks
# for is attached.
curl -fsS -X PATCH \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"$API/releases/$RELEASE_ID" > /dev/null
echo "Published $GITHUB_REF_NAME"
+147
View File
@@ -0,0 +1,147 @@
name: Release
# PRD F8: on tag push v*, build the single-file kigi binary for the five
# supported targets, package per-target archives named
# kigi-<version>-<target-triple>.{tar.gz|zip} (binary + LICENSE + NOTICE +
# THIRD-PARTY-NOTICES), generate SHA256SUMS, and publish everything as a
# GitHub Release. install.sh / install.ps1 and the in-app self-updater
# (kigi-update) both resolve these exact asset names — keep the naming in
# lockstep with auto_update::release_asset_name().
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: build (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
# macos-14 runners are arm64; the x86_64 slice is a cross-compile
# against the same SDK (macos-13 Intel runners are deprecated).
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
# Windows cannot exec the dotslash launcher directly (no shebang
# support → os error 193), and the build script's documented fallback
# is a protoc on PATH — install the same version the dotslash file
# pins (v29.3).
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Package archive (tar.gz)
if: runner.os != 'Windows'
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
shasum -a 256 "$archive" || sha256sum "$archive"
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
- name: Package archive (zip)
if: runner.os == 'Windows'
shell: pwsh
run: |
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP "staging")
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive
Get-FileHash -Algorithm SHA256 $archive
Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE=$archive"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARCHIVE }}
path: ${{ env.ARCHIVE }}
if-no-files-found: error
release:
name: publish GitHub Release
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate SHA256SUMS
working-directory: dist
run: |
ls -l
sha256sum kigi-* > SHA256SUMS
cat SHA256SUMS
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
fail_on_unmatched_files: true
+85
View File
@@ -0,0 +1,85 @@
name: Warm build cache
# Release builds run on TAG refs, and GitHub Actions cache isolation only
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
# No workflow ran on `main`, so every release compiled the whole workspace
# cold on all five targets (~50 min wall clock, gated by Windows). This
# workflow builds the same `release-dist` profile on `main` so tag builds
# restore a warm default-branch cache.
#
# Triggers: dependency-affecting pushes to main (each release's version-bump
# commit warms the cache for the NEXT release), a weekly refresh so the
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
#
# The setup steps mirror release.yml's build job (toolchain, target,
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
# key won't match and releases go back to cold builds.
on:
push:
branches: ["main"]
paths:
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".github/workflows/warm-cache.yml"
schedule:
- cron: "17 5 * * 1"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
concurrency:
group: warm-cache
cancel-in-progress: true
jobs:
warm:
name: warm (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
# Same key as release.yml so tag builds restore this cache verbatim.
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
+69 -12
View File
@@ -17,11 +17,37 @@ import) or any `KIMI_*` env var.
Upstream sync record (update on every sync): fork baseline is upstream
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
the 0.1.13 cycle. Upstream is daily "Synced from monorepo" snapshots —
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
cycle, and through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle.
Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
start any future sync from those two, not from raw diffs.
Known limitations left by the 0.1.15 sync (fix before relying on them):
the Windows frame writer writes through `std::io::stderr`, so a console
that stops reading still blocks exit-path prints behind std's stderr lock
(unix silences fd 2 after a timed-out writer join); the bwrap command
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
a stat error that the profile classification treated as present.
Known limitation left by the 0.1.16 sync: `notifications/cancelled` for a
timed-out MCP call cannot reach a streamable-HTTP server that answers
`tools/call` with a plain JSON body. rmcp 2.2.0's HTTP worker awaits each
POST in turn, so the cancel queues behind the hung call;
`servers/cancel_aware.rs` waits 500 ms, warns, and the transport reset
then drops the connection. stdio and SSE-mode servers get the cancel.
The cure is rmcp 3.x (upstream runs 3.2), a major bump not yet taken.
Evaluated in the 0.1.16 window and left for a later cycle: MCP qualified
tool names past 64 chars (first prove the name never reaches a provider
function list), the auto-update guard for a binary still in use (needs
per-OS process scans), the DA1 fence around the kitty keyboard pop (needs
a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
(rmcp 3.x again), and `pager.toml`, whose writer in
`kigi-pager-render/src/appearance/config.rs` still ends in a bare
`fs::rename` and replaces a symlink.
## Hard constraints
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
@@ -48,6 +74,15 @@ start any future sync from those two, not from raw diffs.
switch that never stuck). Plain rename stays correct only for true moves
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
backups). Write failures must at least `warn!` — never `let _ =`.
- **config.toml writers resolve their destination through
`util::config::config_write_dest` before they name the tmp file.** A
symlinked `~/.kigi/config.toml` (dotfiles) keeps its link: the tmp sits
beside the referent and replaces it. Only that one path is followed, and
only while `user_kigi_home()` resolves (with no home, `kigi_home()` is the
project's own `./.kigi`). A project `.kigi/config.toml` is
repo-controlled, and following its symlink would overwrite whatever file
a hostile repo points it at; there the link itself is replaced.
Credential and managed stores never follow symlinks.
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
this repo). Members sorted; versions inherited from
`workspace.package.version` — the single source of truth for the release
@@ -81,6 +116,24 @@ start any future sync from those two, not from raw diffs.
- Graph node worktrees are removed right after a successful merge-back;
only FAILED nodes keep theirs for postmortem.
## Terminal keyboard
`Shift+Enter` inserts a newline in the prompt wherever the terminal can
report the modifier, and `kitty_skip_reason` decides where that is —
`shift_enter_unavailable` matches on it rather than keeping a second brand
list, because the two drifted once and the UI advertised a chord that never
arrived. Two skips still deliver the modifier: Apple Terminal, where
`route_enter` polls CoreGraphics, and Windows Terminal, where the console
key record carries SHIFT with no protocol at all (a bare ConHost is only
optimistically refined to Windows Terminal, so it keeps advertising
Alt+Enter). Everywhere else — VTE, xterm.js, JediTerm, screen, tmux below
3.3, tmux with `extended-keys off` — the byte on the wire is a plain CR and
no client-side trick recovers it: crossterm 0.28 parses kitty `CSI 13;2u`
but not xterm's `CSI 27;2;13~`, so `modifyOtherKeys` buys nothing. NO VTE
release carries the protocol (checked at tag 0.82.0:
`_vte_keymap_GDK_Return` encodes only Alt); a version cutoff for it is a
bug, not a feature. `/terminal-setup` owns the per-terminal recipe.
## Test seams
Cross-crate test hooks are behind the `test-support` cargo feature
@@ -347,15 +400,18 @@ client-side, no backend surface.
reasoning replay requires `include:["reasoning.encrypted_content"]`.
API-key `openai` Responses requests carry NONE of this
(byte-identical, pinned by a control wire test). `reasoning.effort`
carries the thinking level (incl. the codex-only `ultra`). NO
websocket, NO base_instructions.
carries the thinking level. NO websocket, NO base_instructions.
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
`openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
`supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx
272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI /
`~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and
`gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED.
`platform_wire_model_to_entry` output): the 7 `visibility=list` &&
`supported_in_api=true` models the backend served on 2026-09-22
(`gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx 272000,
per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex`
dependency; the hidden `gpt-reserve` and `codex-auto-review` are
EXCLUDED. Refresh the table by reading
`GET {base}/models?client_version=<a current codex release>` with a
ChatGPT bearer: an old `client_version` is answered with a truncated
list, so a stale version reads as "the model does not exist".
- `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE).
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
@@ -497,10 +553,11 @@ client-side, no backend surface.
`parse_api_json` for bundled + runtime refresh; 24h cache
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
never invents model availability. Canonical reasoning efforts:
none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's
server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat
renames).
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
the Codex backend advertises an `ultra` tier above `max` that kigi has no
level for; Kimi wire spells its top tier `max`, kimi_compat renames).
config.toml accepts any letter case: `ReasoningEffort` deserializes
through `FromStr`.
## Milestones (PRD §8.3)
Generated
+75 -72
View File
@@ -720,9 +720,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.1"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -731,9 +731,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.42.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
@@ -1626,9 +1626,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"async-openai",
"async-stream",
@@ -6141,6 +6141,7 @@ dependencies = [
"futures-util",
"indexmap",
"kigi-sampling-types",
"kigi-secrets",
"kigi-test-support",
"kigi-version",
"reqwest 0.12.28",
@@ -6154,7 +6155,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"chrono",
@@ -6186,13 +6187,14 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"tempfile",
"toml",
"tracing",
]
[[package]]
name = "kigi-secrets"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6293,6 +6295,7 @@ dependencies = [
"kigi-sampler",
"kigi-sampling-types",
"kigi-sandbox",
"kigi-secrets",
"kigi-shared",
"kigi-shell-base",
"kigi-sqlite-journal",
@@ -6367,7 +6370,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.13"
version = "0.1.18"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.13"
version = "0.1.18"
[[package]]
name = "kigi-tty-utils"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"base64",
"chrono",
@@ -8840,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.13"
version = "0.1.18"
dependencies = [
"anyhow",
"axum",
@@ -9633,9 +9636,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -9698,9 +9701,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.13"
version = "0.1.18"
edition = "2024"
license = "Apache-2.0"
+1 -1
View File
@@ -1,7 +1,7 @@
# Security Policy
Kigi is a community build; report vulnerabilities on the
[Kigi issue tracker](https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues).
[Kigi issue tracker](https://github.com/ZacharyZhang-NY/Kigi-CLI/issues).
Issues there are public, so please leave out any detail that would make a
report directly exploitable — open with the affected area and impact, and a
maintainer will follow up for the rest.
+5029 -5028
View File
File diff suppressed because it is too large. Load diff
@@ -252,11 +252,20 @@ pub fn by_name(name: &str) -> Option<AgentDefinition> {
by_name_with_home(name, dirs::home_dir().as_deref(), kigi.as_deref())
}
/// An agent name is one file stem: a separator or `..` would resolve a file outside the searched directory with that directory's trust scope.
fn is_single_path_component(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\'])
}
fn by_name_with_home(
name: &str,
home: Option<&Path>,
kigi_home: Option<&Path>,
) -> Option<AgentDefinition> {
if !is_single_path_component(name) {
tracing::warn!(name, "rejected agent name: not a single path component");
return None;
}
// Check built-ins first — type-safe via BuiltinAgentName strum enum.
// Legacy pre-rebrand agent types (persisted in old session files) are
// mapped onto their current names first; see `canonical_agent_type`.
@@ -298,6 +307,10 @@ fn by_name_in_cwd_with_home(
home: Option<&Path>,
kigi_home: Option<&Path>,
) -> Option<AgentDefinition> {
if !is_single_path_component(name) {
tracing::warn!(name, "rejected agent name: not a single path component");
return None;
}
if let Some(def) = load_project_definition_by_name(name, cwd) {
return Some(def);
}
@@ -827,6 +840,30 @@ mod tests {
assert!(def.is_none());
}
/// `../outside` must not load a file beyond the agents directory under that directory's scope.
#[test]
fn by_name_rejects_names_that_leave_the_agents_directory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let agents_dir = project.join(".kigi").join("agents");
fs::create_dir_all(&agents_dir).unwrap();
write_agent_file(
&project,
"outside.md",
"outside",
"Lives outside the agents dir",
);
write_agent_file(&agents_dir, "inside.md", "inside", "Lives inside");
assert!(by_name_in_cwd_with_home("../../outside", &project, None, None).is_none());
assert!(by_name_in_cwd_with_home("..", &project, None, None).is_none());
assert!(by_name_in_cwd_with_home("a/b", &project, None, None).is_none());
assert!(by_name_with_home("../../outside", None, None).is_none());
assert_eq!(
by_name_in_cwd_with_home("inside", &project, None, None).map(|d| d.name),
Some("inside".to_string())
);
}
#[test]
fn test_discover_finds_md_files() {
let tmp = tempfile::tempdir().unwrap();
+65 -30
View File
@@ -146,27 +146,33 @@ async fn run_setup_command(json: bool) {
.expect("setup report has no non-serializable values");
println!("{out}");
if !report.configured {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
kigi_tui::best_effort_stderr::eprint_line(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
);
}
}
Err(e) => {
eprintln!("Couldn't fetch managed configuration. {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Couldn't fetch managed configuration. {e}"
));
std::process::exit(1);
}
}
return;
}
match managed_config::run_setup().await {
SetupOutcome::Installed => eprintln!("Applied managed configuration."),
SetupOutcome::Installed => {
kigi_tui::best_effort_stderr::eprint_line("Applied managed configuration.")
}
SetupOutcome::NothingConfigured => {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
kigi_tui::best_effort_stderr::eprint_line(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
);
}
SetupOutcome::Failed(e) => {
eprintln!("Couldn't apply managed configuration. {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Couldn't apply managed configuration. {e}"
));
std::process::exit(1);
}
}
@@ -208,8 +214,8 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
println!("{info:#?}");
} else {
print_leader_descriptor(&descriptor);
eprintln!(
" (detailed info unavailable — leader does not advertise control capabilities)"
kigi_tui::best_effort_stderr::eprint_line(
" (detailed info unavailable — leader does not advertise control capabilities)",
);
}
client.cancel();
@@ -220,7 +226,7 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
async fn kill_leaders() -> Result<()> {
let leaders = kigi_shell::leader::discover_leaders().await;
if leaders.is_empty() {
eprintln!("No leader candidates found.");
kigi_tui::best_effort_stderr::eprint_line("No leader candidates found.");
return Ok(());
}
let mut killed = 0u32;
@@ -231,7 +237,9 @@ async fn kill_leaders() -> Result<()> {
};
if !kigi_shell::util::is_kigi_process(pid) {
if let Some(ref lock) = d.lock_path {
eprintln!(" PID {pid} is not a kigi process, removing stale lock");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" PID {pid} is not a kigi process, removing stale lock"
));
let _ = std::fs::remove_file(lock);
cleaned += 1;
}
@@ -240,19 +248,23 @@ async fn kill_leaders() -> Result<()> {
}
continue;
}
eprintln!(" Killing leader PID {pid}");
kigi_tui::best_effort_stderr::eprint_line(&format!(" Killing leader PID {pid}"));
if let Err(e) = kigi_shell::util::kill_process_by_pid(pid) {
eprintln!(" warning: failed to terminate PID {pid}: {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" warning: failed to terminate PID {pid}: {e}"
));
continue;
}
killed += 1;
}
if killed > 0 {
eprintln!("Killed {killed} leader process(es).");
kigi_tui::best_effort_stderr::eprint_line(&format!("Killed {killed} leader process(es)."));
} else if cleaned > 0 {
eprintln!("No live leader processes found (cleaned up {cleaned} stale lock(s)).");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"No live leader processes found (cleaned up {cleaned} stale lock(s))."
));
} else {
eprintln!("No live leader processes found.");
kigi_tui::best_effort_stderr::eprint_line("No live leader processes found.");
}
Ok(())
}
@@ -295,7 +307,7 @@ fn print_leader_descriptor(d: &LeaderDescriptor) {
.map(|p| p.display().to_string())
.unwrap_or_else(|| "?".into());
let state = format!("{:?}", d.classification);
eprintln!(" PID {pid} ({state}) -- {sock}");
kigi_tui::best_effort_stderr::eprint_line(&format!(" PID {pid} ({state}) -- {sock}"));
}
fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value {
serde_json::json!(
@@ -1233,10 +1245,12 @@ fn main() {
// A host out of threads (RLIMIT_NPROC / pids.max) is an
// environment problem, not a bug — report it, don't panic.
kigi_tty_utils::restore_native_stderr();
eprintln!("Error: failed to start the async runtime: {e}");
eprintln!(
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Error: failed to start the async runtime: {e}"
));
kigi_tui::best_effort_stderr::eprint_line(
"The host may be out of threads (RLIMIT_NPROC or the cgroup pids ceiling); \
close other processes or raise the limit, then retry."
close other processes or raise the limit, then retry.",
);
std::process::exit(1);
}
@@ -1245,7 +1259,8 @@ fn main() {
kigi_log::debug_log::flush();
if let Err(e) = result {
kigi_tty_utils::restore_native_stderr();
eprintln!("Error: {e:#}");
// fd 2 may be a closed pane: one attempt, never a panic under panic = "abort".
kigi_tui::best_effort_stderr::eprint_line(&format!("Error: {e:#}"));
std::process::exit(1);
}
}
@@ -1596,16 +1611,25 @@ async fn async_main() -> Result<()> {
let result = kigi_tui::app::run(args, bg_update_rx).await;
kigi_sandbox::flush();
match result {
Ok(true) => {
Ok(exit) if exit.quit_for_update => {
let adopted = bg_update_wait.lock().await.take();
if finish_update_on_exit(adopted, &update_config).await {
eprintln!("Update installed. Run `kigi` to start.");
} else {
eprintln!("Update did not complete. Run `kigi update` to retry.");
let installed =
finish_update_on_exit(adopted, &update_config, exit.terminal_reading).await;
// A terminal that stopped reading at teardown would block these prints.
if exit.terminal_reading {
if installed {
kigi_tui::best_effort_stderr::eprint_line(
"Update installed. Run `kigi` to start.",
);
} else {
kigi_tui::best_effort_stderr::eprint_line(
"Update did not complete. Run `kigi update` to retry.",
);
}
}
Ok(())
}
Ok(false) => Ok(()),
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
@@ -1621,10 +1645,15 @@ async fn async_main() -> Result<()> {
async fn finish_update_on_exit(
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
update_config: &UpdateConfig,
terminal_reading: bool,
) -> bool {
let run_blocking = |reason: Option<String>| async move {
// The blocking updater prints and its child inherits stderr: not on a stalled terminal.
if !terminal_reading {
return false;
}
if let Some(reason) = reason {
eprintln!("{reason}");
kigi_tui::best_effort_stderr::eprint_line(&reason);
}
auto_update::run_update_if_available(
auto_update::UpdateRunMode::Blocking,
@@ -1636,7 +1665,11 @@ async fn finish_update_on_exit(
};
match adopted {
Some(handle) => {
eprintln!("Waiting for the update download to finish...");
if terminal_reading {
kigi_tui::best_effort_stderr::eprint_line(
"Waiting for the update download to finish...",
);
}
match handle.await {
Ok(Ok(status)) if status.success() => true,
Ok(Ok(status)) => {
@@ -1809,7 +1842,9 @@ async fn signal_leaders_to_relaunch(installed_version: &str) {
to_version,
..
})) => {
eprintln!(" ↻ Relaunching shared session (leader {from_version} → {to_version})…");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" ↻ Relaunching shared session (leader {from_version} → {to_version})…"
));
}
Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => {
tracing::debug!(% reason, "Leader declined relaunch");
@@ -390,14 +390,24 @@ impl ChatStateActor {
});
}
/// Replace the entire conversation, persist, re-estimate `total_tokens`,
/// and emit reset + token-update events.
///
/// Compaction replaces carry the provider-side overhead forward as a
/// *ratio* (`base_estimate × provider_total ÷ estimate_at_last_response`,
/// capped at the pre-compaction total; `base_estimate` when that estimate is
/// 0) so the reseed neither springs back nor over-counts (see
/// `COMPACTION.md`).
/// Reseed after a rewrite: the raw estimate scaled by the provider-confirmed ratio, capped at the pre-rewrite total (see COMPACTION.md).
pub(super) fn reseed_total_tokens(&self, base_estimate: u64) -> u64 {
let pre_replace_total = self.state.total_tokens;
let mut estimated_tokens =
if pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
(base_estimate as f64 * ratio).round() as u64
} else {
base_estimate
};
// Over-counting fires premature compaction; an under-count self-heals on the next response.
if pre_replace_total > 0 {
estimated_tokens = estimated_tokens.min(pre_replace_total);
}
estimated_tokens
}
/// Replace the conversation, persist, reseed `total_tokens` and emit reset + token events; `is_compaction` only marks the turn capture.
pub(super) fn replace_conversation(
&mut self,
items: Vec<ConversationItem>,
@@ -407,28 +417,16 @@ impl ChatStateActor {
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
cap.compaction_occurred = true;
}
let pre_replace_total = self.state.total_tokens;
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
// the planner/verifier subagents ran, so their sealed trace turns survive
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
self.persistence.replace_history(&items);
let base_estimate = super::state::estimate_conversation_tokens(&items);
let mut estimated_tokens =
if is_compaction && pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
(base_estimate as f64 * ratio).round() as u64
} else {
base_estimate
};
// Compaction must never appear to increase usage.
if is_compaction && pre_replace_total > 0 {
estimated_tokens = estimated_tokens.min(pre_replace_total);
}
let estimated_tokens = self.reseed_total_tokens(base_estimate);
self.state.conversation = items;
self.state.estimated_tokens_since_model = 0;
self.state.total_tokens = estimated_tokens;
self.state.estimate_at_last_response =
super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.estimate_at_last_response = base_estimate;
self.rebase_turn_capture_offset();
self.send_event(ChatStateEvent::ConversationReset {
new_len: self.state.conversation.len(),
@@ -68,10 +68,10 @@ impl ChatStateActor {
self.state.conversation.truncate(truncate_at);
self.state.prompt_texts.truncate(target_prompt_index);
self.state.prompt_index = target_prompt_index;
self.state.total_tokens =
super::state::estimate_conversation_tokens(&self.state.conversation);
let base_estimate = super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.total_tokens = self.reseed_total_tokens(base_estimate);
self.state.estimated_tokens_since_model = 0;
self.state.estimate_at_last_response = self.state.total_tokens;
self.state.estimate_at_last_response = base_estimate;
self.persistence.replace_history(&self.state.conversation);
@@ -68,12 +68,10 @@ pub fn estimate_item_tokens(item: &ConversationItem) -> u64 {
kigi_token_estimation::estimate_tokens(&b.text_summary())
}
ConversationItem::Reasoning(r) => {
// Summary + content text follow the standard bytes-per-token
// estimate; encrypted blobs are base64 and don't survive
// tokenization 1:1, so estimate at len/4 as well.
// Text and ciphertext are the same reasoning twice: the larger wins (base64 is 4/3 over raw).
let text_bytes = kigi_sampling_types::reasoning_item_text(r).len();
let enc_bytes = r.encrypted_content.as_deref().map(str::len).unwrap_or(0);
((text_bytes + enc_bytes) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
(text_bytes.max(enc_bytes * 3 / 4) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
}
}
}
@@ -658,22 +658,66 @@ async fn compaction_reseed_without_provider_count_matches_plain_estimate() {
}
#[tokio::test]
async fn non_compaction_replace_does_not_carry_overhead() {
async fn non_compaction_replace_carries_confirmed_total() {
// A rewind/mode-switch replace scales from the provider-confirmed count, not the raw estimate.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.record_token_usage(51_000);
h.handle.record_token_usage(500);
// Estimate at last response = 1_000, confirmed = 500 → ratio 0.5.
h.handle
.replace_conversation(vec![ConversationItem::user("q".repeat(4000))]);
let total = h.handle.get_total_tokens().await;
assert_eq!(
total, 1_000,
"non-compaction replace (e.g. rewind) keeps the plain estimate"
total, 500,
"same-size replace carries the provider-confirmed count"
);
}
#[tokio::test]
async fn replace_never_increases_total_tokens() {
// A growing replace is capped at the confirmed total; the under-count self-heals on the next usage.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.record_token_usage(1_500);
h.handle
.replace_conversation(vec![ConversationItem::user("q".repeat(40_000))]);
assert_eq!(h.handle.get_total_tokens().await, 1_500);
}
#[tokio::test]
async fn truncate_scales_from_confirmed_total() {
// Rewind path: TruncateToPromptIndex must use the same carry as replace_conversation.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.increment_prompt_index();
h.handle
.push_user_message(ConversationItem::user("y".repeat(4000)));
h.handle.increment_prompt_index();
h.handle.record_token_usage(1_000);
// Estimate at last response = 2_000, confirmed = 1_000 → ratio 0.5.
h.handle.truncate_to_prompt_index(1).await;
// Keeps the first user item (raw estimate 1_000) → scaled to 500.
assert_eq!(h.handle.get_total_tokens().await, 500);
}
#[test]
fn reasoning_estimate_takes_the_larger_of_text_and_ciphertext() {
// "thinking for r1" is 15 bytes; 800 base64 bytes are ~600 raw bytes.
let encrypted = reasoning_sibling("r1", Some(&"A".repeat(800)));
assert_eq!(crate::estimate_conversation_tokens(&[encrypted]), 150);
let plain = reasoning_sibling("r1", None);
assert_eq!(crate::estimate_conversation_tokens(&[plain]), 3);
}
#[tokio::test]
async fn flush_calls_persistence_flush() {
let mut h = TestHarness::new();
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct KigiEndpoints {
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
coding_api_base_url: "https://api.kimi.com/coding/v1",
oauth_host: "https://auth.kimi.com",
update_base_url: "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases",
update_base_url: "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases",
upgrade_page_url: "https://www.kimi.com/code/",
};
+35 -29
View File
@@ -32,6 +32,9 @@ use rmcp::{
use crate::oauth_config::McpOAuthConfig;
mod cancel_aware;
use cancel_aware::call_tool_cancel_aware;
use kigi_tools::types::{
output::{MCPOutput, MCPOutputDetails, ToolOutput},
tool::{ToolKind, ToolNamespace},
@@ -1599,12 +1602,24 @@ impl McpErasedTool {
let mut params = CallToolRequestParams::new(self.tool.name.clone());
params.arguments = raw.as_object().cloned();
let result =
tokio::time::timeout(timeout_duration, mcp_service.call_tool(params.clone())).await;
match result {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(service_err))
match call_tool_cancel_aware(&mcp_service, params.clone(), timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
Err(service_err)
if should_recover_service_error(
&service_err,
client.is_http(),
@@ -1623,25 +1638,10 @@ impl McpErasedTool {
)
.await
}
Ok(Err(e)) => Err(kigi_tool_runtime::ToolError::custom(
Err(e) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
e.to_string(),
)),
Err(_) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
}
}
@@ -1692,13 +1692,9 @@ impl McpErasedTool {
));
}
};
match tokio::time::timeout(timeout_duration, mcp_service.call_tool(params)).await {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(retry_err)) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
Err(_) => {
match call_tool_cancel_aware(&mcp_service, params, timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
@@ -1708,6 +1704,10 @@ impl McpErasedTool {
),
))
}
Err(retry_err) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
}
}
}
@@ -6223,11 +6223,17 @@ mod tests {
let mut reconnect = false;
let mut is_timeout = false;
let raw = serde_json::json!({});
let started = std::time::Instant::now();
let err = tool
.try_call_tool(&client, &raw, &mut reconnect, &mut is_timeout, &ew)
.await
.expect_err("call must time out");
assert!(
started.elapsed() < std::time::Duration::from_millis(2500),
"cancel must not wait out the 3s hang: {:?}",
started.elapsed()
);
assert!(err.to_string().contains("timed out"), "got: {err}");
assert!(is_timeout, "is_timeout must be set");
assert!(reconnect, "timeout arm flags the reconnect after resetting");
@@ -0,0 +1,224 @@
//! `tools/call` that tells the server when the host stops waiting.
use rmcp::model::{
CallToolRequest, CallToolRequestParams, CallToolResult, CancelledNotificationParam,
ClientRequest, RequestId, ServerResult,
};
use rmcp::service::{Peer, PeerRequestOptions};
use rmcp::{RoleClient, ServiceError};
use super::McpService;
/// rmcp 2.2.0's HTTP worker queues the cancel behind a hung POST, so the wait is bounded.
const CANCEL_DELIVERY_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
/// A timeout cancels inline, before the caller can reset the transport; a drop cancels from a task.
pub(super) async fn call_tool_cancel_aware(
service: &McpService,
params: CallToolRequestParams,
timeout: std::time::Duration,
) -> Result<CallToolResult, ServiceError> {
let handle = service
.peer()
.send_cancellable_request(
ClientRequest::CallToolRequest(CallToolRequest::new(params)),
PeerRequestOptions::no_options(),
)
.await?;
let mut guard = CancelOnDrop {
peer: service.peer().clone(),
request_id: Some(handle.id.clone()),
};
let Ok(response) = tokio::time::timeout(timeout, handle.await_response()).await else {
guard.cancel_within_budget().await;
return Err(ServiceError::Timeout { timeout });
};
guard.request_id = None;
match response? {
ServerResult::CallToolResult(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
/// Armed while the request is in flight; disarmed once it settles.
struct CancelOnDrop {
peer: Peer<RoleClient>,
request_id: Option<RequestId>,
}
impl CancelOnDrop {
async fn cancel_within_budget(&mut self) {
let Some(request_id) = self.request_id.clone() else {
return;
};
let delivery = notify_cancelled(self.peer.clone(), request_id.clone());
if tokio::time::timeout(CANCEL_DELIVERY_BUDGET, delivery)
.await
.is_err()
{
tracing::warn!(
?request_id,
"notifications/cancelled still queued after budget"
);
}
self.request_id = None;
}
}
impl Drop for CancelOnDrop {
fn drop(&mut self) {
let Some(request_id) = self.request_id.take() else {
return;
};
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
tracing::warn!(
?request_id,
"MCP call dropped outside a runtime; cancel not sent"
);
return;
};
runtime.spawn(notify_cancelled(self.peer.clone(), request_id));
}
}
async fn notify_cancelled(peer: Peer<RoleClient>, request_id: RequestId) {
let params = CancelledNotificationParam::new(
Some(request_id.clone()),
Some("client cancelled".to_owned()),
);
if let Err(e) = peer.notify_cancelled(params).await {
tracing::warn!(?request_id, error = %e, "notifications/cancelled not delivered");
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use rmcp::ServiceExt;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use super::super::{KigiClientHandler, McpClient};
use super::*;
type Received = Arc<parking_lot::Mutex<Vec<Value>>>;
/// Duplex MCP server: answers `initialize`, records all, answers `tools/call` only when `reply`.
async fn recording_service(reply: bool) -> (McpService, Received) {
let received = Received::default();
let (client_read, mut server_write) = tokio::io::duplex(64 * 1024);
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
let sink = received.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(server_read).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg: Value = serde_json::from_str(&line).expect("client sends JSON");
sink.lock().push(msg.clone());
let result = match msg["method"].as_str() {
Some("initialize") => serde_json::json!({
"protocolVersion": msg["params"]["protocolVersion"],
"capabilities": { "tools": {} },
"serverInfo": { "name": "recording", "version": "0.0.0" },
}),
Some("tools/call") if reply => serde_json::json!({
"content": [{ "type": "text", "text": "done" }],
"isError": false,
}),
_ => continue,
};
let response =
serde_json::json!({ "jsonrpc": "2.0", "id": msg["id"], "result": result });
let encoded = format!("{response}\n");
server_write
.write_all(encoded.as_bytes())
.await
.expect("client reads replies");
}
});
let handler = KigiClientHandler {
info: McpClient::make_client_info("recording"),
server_name: "recording".to_string(),
notify_tx: Arc::default(),
};
let transport = rmcp::transport::async_rw::AsyncRwTransport::<RoleClient, _, _>::new(
client_read,
client_write,
);
let service = Arc::new(handler.serve(transport).await.expect("handshake"));
(service, received)
}
fn values_at(received: &Received, method: &str, pointer: &str) -> Vec<Value> {
received
.lock()
.iter()
.filter(|m| m["method"] == method)
.map(|m| m.pointer(pointer).cloned().unwrap_or(Value::Null))
.collect()
}
fn call_ids(received: &Received) -> Vec<Value> {
values_at(received, "tools/call", "/id")
}
fn cancelled_ids(received: &Received) -> Vec<Value> {
values_at(received, "notifications/cancelled", "/params/requestId")
}
#[tokio::test]
async fn dropped_call_cancels_its_request() {
let (service, received) = recording_service(false).await;
let mut call = Box::pin(call_tool_cancel_aware(
&service,
CallToolRequestParams::new("slow"),
Duration::from_secs(30),
));
let pending = tokio::time::timeout(Duration::from_millis(100), &mut call).await;
assert!(pending.is_err(), "server never replies");
drop(call);
tokio::time::sleep(Duration::from_millis(100)).await;
let ids = call_ids(&received);
assert_eq!(ids.len(), 1, "{ids:?}");
assert_eq!(cancelled_ids(&received), ids);
}
#[tokio::test]
async fn timed_out_call_cancels_its_request_once() {
let (service, received) = recording_service(false).await;
let round = call_tool_cancel_aware(
&service,
CallToolRequestParams::new("slow"),
Duration::from_millis(50),
)
.await;
assert!(
matches!(round, Err(ServiceError::Timeout { .. })),
"{round:?}"
);
// The HTTP timeout arm resets the transport, which drops the last service handle.
drop(service);
tokio::time::sleep(Duration::from_millis(100)).await;
let ids = call_ids(&received);
assert_eq!(ids.len(), 1, "{ids:?}");
assert_eq!(cancelled_ids(&received), ids);
}
#[tokio::test]
async fn completed_call_sends_no_cancellation() {
let (service, received) = recording_service(true).await;
let result = call_tool_cancel_aware(
&service,
CallToolRequestParams::new("fast"),
Duration::from_secs(5),
)
.await
.expect("server replies");
assert_eq!(result.is_error, Some(false));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(cancelled_ids(&received), Vec::<Value>::new());
}
}
@@ -1 +1 @@
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"kimi-k2.5":{"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":16384},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"Qwen3.7 Max","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-plus":{"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image"],"output":["text"]},"name":"Qwen3.7 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.8-max-preview":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.8 Max Preview","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","xhigh"]},{"max":262144,"min":0,"type":"budget_tokens"}],"tool_call":true},"wan2.7-image":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image","reasoning":false,"tool_call":false},"wan2.7-image-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image Pro","reasoning":false,"tool_call":false}}},"alibaba-token-plan-cn":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":3Line truncated
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":32768},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-flash-0731":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash 0731","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro-0813":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro 0813","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4.1-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"],"output":["text"]},"name":"DeepSeek V4.1 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5.3":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.3","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"tool_call":true},"happyhorse-1.1-i2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Image-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-r2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Reference-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-t2v":{"limit":{"context":0,"output":0},"modalities":{"input":["text"],"output":["video"]},"name":"HappyHorse 1.1 Text-to-Video","reasoning":false,"tool_call":false},"kimi-k2.5":{"limit":{"context":262144,"output":98304},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,Line truncated
+16 -1
View File
@@ -223,7 +223,7 @@ mod tests {
"snapshot lost providers: {}",
catalog.len()
);
let k3 = lookup(catalog, "kimi-for-coding", "k3").expect("k3 present");
let k3 = lookup(catalog, "kimi-code-plan-cn", "k3").expect("k3 present");
assert_eq!(k3.context, 1_048_576, "k3 context must match the live wire");
assert_eq!(k3.efforts, ["low", "high", "max"]);
assert!(k3.reasoning);
@@ -232,6 +232,21 @@ mod tests {
assert_eq!(opus.efforts, ["low", "medium", "high", "xhigh", "max"]);
}
/// `restrict_to_enriched` drops a live listing id the enrichment catalog
/// does not know, so the bundled snapshot must admit the current flagship
/// models offline: grok-4.7 on xai (the `xai`/`xai-grok` rows) and
/// gpt-6-astra on the API-key `openai` row.
#[test]
fn bundled_snapshot_admits_the_current_flagship_models() {
let catalog = bundled_enrichment();
let grok = lookup(catalog, "xai", "grok-4.7").expect("grok-4.7 present");
assert!(grok.tool_call, "a non-tool-calling model is dropped");
assert_eq!(grok.context, 500_000);
let astra = lookup(catalog, "openai", "gpt-6-astra").expect("gpt-6-astra present");
assert!(astra.tool_call);
assert_eq!(astra.context, 1_050_000);
}
/// Wire-served fields are never overwritten; absent fields are filled.
#[test]
fn enrich_fills_gaps_and_never_overwrites_wire() {
+64 -25
View File
@@ -400,7 +400,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: None,
login_label: None,
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -987,7 +987,7 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: Some("www.kimi.com"),
login_label: Some("Kimi For Coding (API key)"),
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -1570,9 +1570,10 @@ impl PlatformId {
/// The compiled-in catalog for a platform that serves NO live `/models`
/// listing (openai-codex), or `None` when the catalog comes from the wire.
///
/// openai-codex's 4 models are HARDCODED (read from the official Codex CLI's
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
/// set) because OpenAI exposes no stable public models endpoint for the
/// openai-codex's 7 models are HARDCODED (read from the Codex backend
/// catalog on 2026-09-22, the `visibility=="list"` AND
/// `supported_in_api==true` set) because OpenAI exposes no stable public
/// models endpoint for the
/// ChatGPT Codex backend. Each entry carries context window + per-model
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
/// tiers), so the fetch path maps them through the SAME
@@ -1606,14 +1607,33 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
}
}
/// The HARDCODED openai-codex catalog: exactly the 4 `visibility=="list"` AND
/// `supported_in_api==true` models from the Codex CLI model cache. The
/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses),
/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models
/// are deliberately EXCLUDED — they would list-but-not-work or are not
/// user-facing (fail-fast: never advertise a model the backend rejects).
/// The HARDCODED openai-codex catalog: exactly the 7 `visibility=="list"` AND
/// `supported_in_api==true` models the Codex backend served on 2026-09-22, in
/// its own priority order. `gpt-reserve` and `codex-auto-review`
/// (visibility="hide") are deliberately EXCLUDED — they are not user-facing
/// (fail-fast: never advertise a model the backend rejects). Several models
/// also advertise an `ultra` tier above `max`; kigi has no such level, so it
/// is not listed here.
fn openai_codex_wire_models() -> Vec<WireModel> {
vec![
codex_wire_model(
"gpt-6-astra",
"GPT-6-Astra",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-6-sol",
"GPT-6-Sol",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-6-luna",
"GPT-6-Luna",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-5.6-sol",
"GPT-5.6-Sol",
@@ -2653,29 +2673,32 @@ mod tests {
assert_eq!(c.base_url(), "https://mock.codex/codex");
}
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
/// The HARDCODED openai-codex catalog is exactly the 7 supported+listed
/// models, keyed by slug, ctx 272000, each exposing its exact supported
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The
/// list-but-broken / hidden models are absent. Every other platform serves
/// NO hardcoded catalog (its models come from the live wire).
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The hidden models
/// are absent. Every other platform serves NO hardcoded catalog (its
/// models come from the live wire).
#[test]
fn openai_codex_hardcoded_catalog_is_the_four_supported_models() {
fn openai_codex_hardcoded_catalog_is_the_seven_supported_models() {
let catalog = PlatformId::OpenaiCodex
.hardcoded_catalog()
.expect("openai-codex serves a hardcoded catalog");
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
"exactly the 4 visibility=list AND supported_in_api=true models"
vec![
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5"
],
"exactly the 7 visibility=list AND supported_in_api=true models"
);
// Excluded: list-but-not-served + hidden models never appear.
for absent in [
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"codex-auto-review",
] {
// Excluded: hidden models never appear.
for absent in ["gpt-reserve", "codex-auto-review"] {
assert!(
!ids.contains(&absent),
"{absent} must be excluded from the hardcoded catalog"
@@ -2700,6 +2723,19 @@ mod tests {
.valid_efforts
.clone()
};
assert_eq!(
efforts("gpt-6-astra"),
["low", "medium", "high", "xhigh", "max"],
"`ultra` is advertised above `max` but kigi has no such level"
);
assert_eq!(
efforts("gpt-6-sol"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-6-luna"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-5.6-sol"),
["low", "medium", "high", "xhigh", "max"]
@@ -2725,6 +2761,9 @@ mod tests {
.default_effort
.clone()
};
assert_eq!(default("gpt-6-astra").as_deref(), Some("medium"));
assert_eq!(default("gpt-6-sol").as_deref(), Some("medium"));
assert_eq!(default("gpt-6-luna").as_deref(), Some("medium"));
assert_eq!(default("gpt-5.6-sol").as_deref(), Some("low"));
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
// Only openai-codex has a hardcoded catalog; every wire platform is None.
@@ -146,6 +146,10 @@ impl TermWriter {
pub fn writer_sync(&self) -> &WriterSync {
&self.sync
}
/// A cloneable handle onto this writer's queue for out-of-band escapes.
pub fn escape_writer(&self) -> EscapeWriter {
EscapeWriter::new(self.tx.clone(), self.sync.clone())
}
}
impl Write for TermWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
@@ -166,6 +170,56 @@ impl Drop for TermWriter {
let _ = self.flush();
}
}
/// Out-of-band escapes ride the frame queue (an inline stderr write deadlocks on a stalled tty); drop every clone before `join_within`.
#[derive(Clone)]
pub struct EscapeWriter {
tx: mpsc::Sender<Vec<u8>>,
sync: WriterSync,
}
impl EscapeWriter {
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
Self { tx, sync }
}
/// No writer thread behind it: sends are dropped. For tests and views built before the terminal exists.
pub fn disconnected() -> Self {
let (tx, _rx) = mpsc::channel::<Vec<u8>>();
Self {
tx,
sync: WriterSync::new(),
}
}
/// Never blocks; covered by [`WriterSync::wait_drained`].
pub fn emit(&self, bytes: impl Into<Vec<u8>>) {
let data: Vec<u8> = bytes.into();
if data.is_empty() {
return;
}
self.sync.mark_queued();
let _ = self.tx.send(data);
}
/// Winapi-only commands run synchronously: a console API call, not a tty write.
pub fn emit_command(&self, command: impl crossterm::Command) {
#[cfg(windows)]
if !command.is_ansi_code_supported() {
let _ = command.execute_winapi();
return;
}
let mut ansi = String::new();
if command.write_ansi(&mut ansi).is_ok() {
self.emit(ansi);
}
}
}
/// Outcome of [`WriterThread::join_within`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriterJoin {
/// The thread drained its queue and exited.
Joined,
/// Still running at the deadline (tty blocked, or a sender alive); detached.
TimedOut,
}
/// Teardown waits this long for the writer thread before detaching it, so a stalled tty cannot hang `/quit`.
pub const WRITER_JOIN_GRACE: Duration = Duration::from_secs(2);
/// Handle for the background writer thread.
///
/// Joining ensures all queued frames have been written to the terminal
@@ -174,19 +228,45 @@ pub struct WriterThread {
handle: Option<std::thread::JoinHandle<()>>,
}
impl WriterThread {
/// Block until the writer thread has processed all pending frames and
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
/// otherwise the thread will never see the channel close.
pub fn join(mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
/// Wait up to `grace` for the thread to drain and exit, else detach it; every sender (`EscapeWriter` clones too) must be dropped first.
pub fn join_within(mut self, grace: Duration) -> WriterJoin {
let Some(handle) = self.handle.take() else {
return WriterJoin::Joined;
};
if wait_finished(&handle, grace) {
let _ = handle.join();
WriterJoin::Joined
} else {
tracing::warn!(
grace_ms = grace.as_millis() as u64,
"term-writer thread still running at teardown; detaching"
);
WriterJoin::TimedOut
}
}
#[cfg(test)]
fn for_test(handle: std::thread::JoinHandle<()>) -> Self {
Self {
handle: Some(handle),
}
}
}
fn wait_finished(handle: &std::thread::JoinHandle<()>, grace: Duration) -> bool {
let deadline = Instant::now() + grace;
while !handle.is_finished() {
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(1));
}
true
}
impl Drop for WriterThread {
fn drop(&mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
if let Some(handle) = self.handle.take()
&& wait_finished(&handle, WRITER_JOIN_GRACE)
{
let _ = handle.join();
}
}
}
@@ -197,7 +277,7 @@ impl Drop for WriterThread {
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
/// Drop the sender to signal the thread to exit, then call
/// [`WriterThread::join`] to wait for it.
/// [`WriterThread::join_within`] to wait for it.
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
@@ -570,4 +650,47 @@ mod tests {
"Round-tripped bytes do not decode to original UTF-8 string"
);
}
/// Frames and escapes share one queue, so the writer sees them in call order.
#[test]
#[cfg(not(windows))]
fn escapes_share_the_frame_queue_in_call_order() {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
let mut frames = TermWriter::new(tx, sync.clone());
let escapes = frames.escape_writer();
frames.write_all(b"frame-1").unwrap();
frames.flush().unwrap();
escapes.emit("\x1b[?1000h");
frames.write_all(b"frame-2").unwrap();
frames.flush().unwrap();
escapes.emit_command(crossterm::event::DisableMouseCapture);
let payloads: Vec<Vec<u8>> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
assert_eq!(payloads.len(), 4);
assert_eq!(payloads[0], b"frame-1");
assert_eq!(payloads[1], b"\x1b[?1000h");
assert_eq!(payloads[2], b"frame-2");
assert!(payloads[3].starts_with(b"\x1b["), "{:?}", payloads[3]);
assert!(!sync.is_drained(), "four payloads queued, none written");
}
/// A writer thread parked in a blocked tty write must not hang teardown.
#[test]
fn join_within_times_out_on_a_stuck_writer_thread() {
let (release_tx, release_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
let _ = release_rx.recv();
});
let started = Instant::now();
let outcome = WriterThread::for_test(handle).join_within(Duration::from_millis(50));
assert_eq!(outcome, WriterJoin::TimedOut);
assert!(started.elapsed() < Duration::from_secs(2));
let _ = release_tx.send(());
}
#[test]
fn join_within_joins_a_finished_writer_thread() {
let handle = std::thread::spawn(|| {});
assert_eq!(
WriterThread::for_test(handle).join_within(Duration::from_secs(5)),
WriterJoin::Joined
);
}
}
@@ -375,7 +375,6 @@ impl TerminalContext {
return Some("apple_terminal");
}
if self.is_vte_based() {
// WHY: central helper replaces VTE duplication
return Some("vte");
}
if self.brand == TerminalName::WindowsTerminal {
@@ -429,74 +428,29 @@ impl TerminalContext {
}
/// Whether the running terminal cannot distinguish `Shift+Enter` from
/// bare `Enter` at the byte level, so the UI should advertise
/// `Alt+Enter` for newline insertion instead.
/// bare `Enter`, so the UI should advertise `Alt+Enter` (delivered as
/// `ESC`+`CR`) for newline insertion instead.
///
/// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol
/// (KKP) to be negotiated. This returns `true` for the environments
/// where the pager cannot rely on KKP for a usable `Shift+Enter`:
/// Follows [`Self::kitty_skip_reason`] rather than a second brand list:
/// when the protocol is not negotiated the modifier is lost, and the two
/// drifted once already (VTE advertised a chord that never arrived — its
/// `Return` keymap encodes only Alt, through 0.82.0 at least, and no VTE
/// release carries the protocol). Two skips still deliver it — Apple
/// Terminal drops the flags but `route_enter` polls CoreGraphics, and
/// Windows console key records carry SHIFT with no protocol at all.
///
/// 1. **Legacy VTE** (GNOME Terminal, Ptyxis, kgx, Tilix, etc.) whose
/// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with
/// KKP, merged in
/// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)).
/// Also true when the brand is detected as VTE but `VTE_VERSION` is
/// missing or unparseable — we conservatively assume old.
/// 2. **VS Code's integrated terminal (xterm.js) and VS Code-family /
/// xterm.js IDE forks**. xterm.js only partially implements KKP —
/// it mis-encodes shifted printable keys — so the pager deliberately
/// never negotiates KKP for them (see [`Self::kitty_skip_reason`]
/// `== "vscode"` and [xterm.js#5823](https://github.com/xtermjs/xterm.js/issues/5823)).
/// Without KKP, xterm.js sends a bare `CR` for `Shift+Enter`,
/// byte-for-byte identical to `Enter`.
/// 3. **Unidentified terminals with no multiplexer**, where the pager
/// also skips KKP (no positive evidence of support — typically
/// VS Code's xterm.js reached over SSH, where `TERM_PROGRAM` isn't
/// forwarded and the brand falls back to `Unknown`).
///
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
/// newline chord and is what the UI advertises.
/// KNOWN GAP: WezTerm inside screen or an old tmux gets Alt+Enter as the
/// advice while its own default binding spends that chord on
/// ToggleFullScreen. Neither chord reaches kigi there; the multiplexer
/// warning carries the real fix, and `\`+Enter still inserts a newline.
pub fn shift_enter_unavailable(&self) -> bool {
// WHY: central helper + version gating
let is_vte = self.is_vte_based();
if is_vte {
return match self
.vte_version
.as_deref()
.and_then(|v| v.parse::<u32>().ok())
{
Some(ver) => ver < 8200,
// Brand=Vte but no parseable version — conservative: assume old.
None => true,
};
match self.kitty_skip_reason() {
None | Some("apple_terminal") => false,
// A bare ConHost is only optimistically refined to Windows
// Terminal; it still advertises Alt+Enter.
Some("windows_terminal") => self.env_brand.is_capability_unclassified(),
Some(_) => true,
}
// VS Code / xterm.js and its forks: KKP is never negotiated, so
// Shift+Enter arrives as a bare CR indistinguishable from Enter.
if matches!(
self.brand,
TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed
) {
return true;
}
// Unidentified / unclassified brand with no multiplexer: KKP is
// skipped (see `kitty_skip_reason`). This is the common
// VS Code-over-SSH shape (brand falls back to Unknown). On native
// Windows the effective `brand` is refined to WindowsTerminal, so
// consult `env_brand` — a bare ConHost is still env-Unknown and
// must advertise Alt+Enter even though we optimistically treat it
// as WT for capabilities.
if self.env_brand.is_capability_unclassified()
&& self.multiplexer == MultiplexerKind::Undetected
{
return true;
}
false
}
/// True when `Ctrl+.` cannot be delivered reliably as a shortcuts primary.
@@ -1443,7 +1443,7 @@ fn kitty_skip_vscode_over_tmux() {
#[test]
fn kitty_skip_vte_version() {
// VTE does not support Kitty keyboard protocol and crossterm's probe
// No VTE release has the Kitty keyboard protocol and crossterm's probe
// can false-positive on it. https://gitlab.gnome.org/GNOME/vte/-/issues/2601
let ctx = TerminalContext {
vte_version: Some("7402".to_owned()),
@@ -1461,73 +1461,68 @@ fn kitty_skip_vte_brand() {
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
}
// shift_enter_unavailable: VTE version gating for Shift+Enter
// shift_enter_unavailable: VTE never delivers Shift+Enter
//
// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the
// Kitty keyboard protocol; earlier versions cannot distinguish
// Shift+Enter from bare Enter, so the UI should advertise Alt+Enter
// for newline insertion instead.
// No VTE release carries the Kitty keyboard protocol: at 0.82.0 the word
// "kitty" appears nowhere in the parser or widget, and
// `_vte_keymap_GDK_Return` encodes only Alt (`ESC CR`), so Shift+Enter is
// the same bare CR as Enter. The UI advertises Alt+Enter instead.
#[test]
fn shift_enter_unavailable_legacy_vte_version() {
// VTE 0.64.2 (real user report) — well below the KKP cutoff.
let ctx = TerminalContext {
vte_version: Some("6402".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_on_every_vte() {
for version in [Some("6402"), Some("8200"), Some("8401"), None] {
let ctx = TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: version.map(str::to_owned),
..Default::default()
};
assert_eq!(ctx.kitty_skip_reason(), Some("vte"), "VTE {version:?}");
assert!(ctx.shift_enter_unavailable(), "VTE {version:?}");
}
}
#[test]
fn shift_enter_unavailable_just_below_cutoff() {
// VTE 0.81.99 — anything below 8200 must return true.
let ctx = TerminalContext {
vte_version: Some("8199".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_modern_vte() {
// VTE 0.82.0 — first release with KKP.
let ctx = TerminalContext {
vte_version: Some("8200".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_future_vte() {
// VTE 0.84.1 — well above the cutoff.
let ctx = TerminalContext {
vte_version: Some("8401".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_vte_brand_no_version() {
// Brand detected as VTE but VTE_VERSION missing — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: None,
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_unparseable_version() {
// Garbage in VTE_VERSION — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: Some("not-a-number".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_follows_every_kitty_skip() {
// A skipped protocol means the modifier never arrives, whatever the
// reason — JediTerm, screen and a multiplexer that eats extended keys
// included. Apple Terminal and Windows Terminal are the two skips with
// another way in.
for ctx in [
TerminalContext {
brand: TerminalName::JetBrains,
env_brand: TerminalName::JetBrains,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Tmux,
tmux_version: Some("3.2".to_owned()),
..Default::default()
},
// A VTE carrying a recent VTE_VERSION is no different.
TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: Some("8200".to_owned()),
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
] {
assert!(ctx.kitty_skip_reason().is_some());
assert!(
ctx.shift_enter_unavailable(),
"{:?} skips the protocol, so Shift+Enter cannot arrive",
ctx.brand
);
}
}
#[test]
@@ -267,6 +267,13 @@ struct WrapCache {
lines: Vec<Range<usize>>,
}
/// Whether a wrapped row's exclusive end is a cursor position on that row.
#[derive(Debug, Clone, Copy)]
enum RowEnd {
HardBreak,
SoftWrap,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TextAreaState {
/// Index into wrapped lines of the first visible line.
@@ -382,27 +389,13 @@ impl TextArea {
}
}
/// Clamp a buffer position so it stays within a wrapped line's range
/// `[line_start, line_end)`. Without this, `display_col_to_buffer_pos`
/// can return `line_end` when the column exceeds the line's display
/// width — and `line_end` equals the *next* wrapped line's start,
/// which confuses `effective_scroll` into thinking the cursor hasn't
/// actually moved to the target line.
///
/// Uses `self.text` to find the last valid char boundary inside the line
/// so we never land in the middle of a multi-byte character.
/// Clamp `pos` to the start of the last grapheme in `[line_start, line_end)`.
fn clamp_to_line(&self, pos: usize, line_start: usize, line_end: usize) -> usize {
if line_end > line_start {
// Find the start of the last character in the line.
let last_char_start = self.text[line_start..line_end]
.char_indices()
.next_back()
.map(|(i, _)| line_start + i)
.unwrap_or(line_start);
pos.min(last_char_start)
} else {
line_start
}
let last_grapheme_start = self.text[line_start..line_end]
.grapheme_indices(true)
.next_back()
.map_or(line_start, |(i, _)| line_start + i);
pos.min(last_grapheme_start)
}
pub fn new() -> Self {
@@ -1699,6 +1692,35 @@ impl TextArea {
if idx == 0 { None } else { Some(idx - 1) }
}
/// Soft-wrapped rows are byte-adjacent; a hard row leaves its newline between it and the next.
fn row_with_end(lines: &[Range<usize>], idx: usize) -> Option<(Range<usize>, RowEnd)> {
let row = lines.get(idx)?;
let end_kind = match lines.get(idx + 1) {
Some(next) if next.start == row.end => RowEnd::SoftWrap,
_ => RowEnd::HardBreak,
};
Some((row.clone(), end_kind))
}
fn clamp_to_row(&self, pos: usize, row: &Range<usize>, end_kind: RowEnd) -> usize {
match end_kind {
RowEnd::HardBreak => pos,
RowEnd::SoftWrap => {
let last_grapheme_start = self.clamp_to_line(pos, row.start, row.end);
let Some(idx) = self.find_element_containing(last_grapheme_start) else {
return last_grapheme_start;
};
let element_start = self.elements[idx].range.start;
// A display-less element wraps as text; one that began on an earlier row keeps `pos`.
if element_start < row.start {
pos
} else {
element_start
}
}
}
}
/// Map a display column to a buffer byte position on a given wrapped line.
///
/// Pure query — does not mutate any state. Handles elements (snapping to
@@ -1791,13 +1813,12 @@ impl TextArea {
fn move_to_display_col_on_line(
&mut self,
line_start: usize,
line_end: usize,
row: Range<usize>,
target_col: usize,
end_kind: RowEnd,
) {
self.cursor_pos = self
.display_col_to_buffer_pos(line_start, line_end, target_col)
.0;
let (pos, _hit_element) = self.display_col_to_buffer_pos(row.start, row.end, target_col);
self.cursor_pos = self.clamp_to_row(pos, &row, end_kind);
}
fn beginning_of_line(&self, pos: usize) -> usize {
@@ -2652,14 +2673,10 @@ impl TextArea {
let target_col = self.preferred_col.unwrap_or_else(|| {
self.display_width_of_range(cur_range.start, self.cursor_pos)
});
if idx > 0 {
let prev = &lines[idx - 1];
let line_start = prev.start;
let line_end = prev.end;
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
let prev = idx
.checked_sub(1)
.and_then(|prev_idx| Self::row_with_end(lines, prev_idx));
Some((target_col, prev))
} else {
None
}
@@ -2669,11 +2686,11 @@ impl TextArea {
} {
// We had wrapping info. Apply movement accordingly.
match maybe_line {
Some((line_start, line_end)) => {
Some((prev, end_kind)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
self.move_to_display_col_on_line(prev, target_col, end_kind);
return;
}
None => {
@@ -2697,7 +2714,11 @@ impl TextArea {
};
let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
let prev_line_end = prev_nl;
self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
self.move_to_display_col_on_line(
prev_line_start..prev_line_end,
target_col,
RowEnd::HardBreak,
);
} else {
self.cursor_pos = 0;
self.preferred_col = None;
@@ -2716,14 +2737,7 @@ impl TextArea {
let target_col = self.preferred_col.unwrap_or_else(|| {
self.display_width_of_range(cur_range.start, self.cursor_pos)
});
if idx + 1 < lines.len() {
let next = &lines[idx + 1];
let line_start = next.start;
let line_end = next.end;
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
Some((target_col, Self::row_with_end(lines, idx + 1)))
} else {
None
}
@@ -2732,11 +2746,11 @@ impl TextArea {
}
} {
match move_to_last {
Some((line_start, line_end)) => {
Some((next, end_kind)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
self.move_to_display_col_on_line(next, target_col, end_kind);
return;
}
None => {
@@ -2766,7 +2780,11 @@ impl TextArea {
.find('\n')
.map(|i| i + next_line_start)
.unwrap_or(self.text.len());
self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
self.move_to_display_col_on_line(
next_line_start..next_line_end,
target_col,
RowEnd::HardBreak,
);
} else {
self.cursor_pos = self.text.len();
self.preferred_col = None;
@@ -3899,6 +3917,9 @@ fn truncate_line_display(line: &Line<'static>, max_width: usize) -> Line<'static
Line::from(new_spans)
}
#[cfg(test)]
mod soft_wrap_nav_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -6293,6 +6314,7 @@ mod tests {
assert_eq!(t.cursor(), "👍👍".len());
}
/// Each ZWJ cluster fills a 4-col row; Up from the end lands on row 1's cluster start.
#[test]
fn wrapped_navigation_with_zwj_graphemes() {
let grapheme = "👩\u{200D}💻";
@@ -6306,7 +6328,7 @@ mod tests {
assert!(pos_after_down >= grapheme.len() * 2);
t.move_cursor_up();
assert_eq!(t.cursor(), grapheme.len() * 2);
assert_eq!(t.cursor(), grapheme.len());
}
#[test]
@@ -0,0 +1,121 @@
//! Up/Down across soft-wrapped rows: a soft row's exclusive end is the next row's start.
use rand::prelude::*;
use ratatui::layout::Rect;
use ratatui::text::Line;
use super::{ElementKind, TextArea};
fn ta_with(text: &str) -> TextArea {
let mut t = TextArea::new();
t.insert_str(text);
t
}
fn cursor_row(t: &TextArea, width: u16) -> usize {
TextArea::wrapped_line_index_by_start(&t.wrapped_lines(width), t.cursor()).unwrap()
}
#[test]
fn up_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
let width = 8;
let mut t = ta_with("aaa bb ccccccc");
assert_eq!(vec![0..7, 7..14], *t.wrapped_lines(width));
t.move_cursor_up();
assert_eq!(6, t.cursor());
assert_eq!(0, cursor_row(&t, width));
assert_eq!(Some((6, 0)), t.cursor_pos(Rect::new(0, 0, width, 5)));
t.move_cursor_down();
assert_eq!(14, t.cursor());
t.move_cursor_up();
assert_eq!(6, t.cursor());
t.move_cursor_up();
assert_eq!(0, t.cursor());
}
#[test]
fn up_onto_newline_terminated_row_lands_on_the_newline() {
let mut t = ta_with("ab\ncdef");
assert_eq!(vec![0..2, 3..7], *t.wrapped_lines(8));
t.move_cursor_up();
assert_eq!(2, t.cursor());
assert_eq!(0, cursor_row(&t, 8));
}
#[test]
fn down_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
let mut t = ta_with("ccccccc aaa bb ddddddd");
assert_eq!(vec![0..8, 8..15, 15..22], *t.wrapped_lines(8));
t.set_cursor(7);
t.move_cursor_down();
assert_eq!(14, t.cursor());
assert_eq!(1, cursor_row(&t, 8));
t.move_cursor_down();
assert_eq!(22, t.cursor());
}
#[test]
fn up_onto_soft_row_closed_by_element_lands_before_the_element() {
let mut t = TextArea::new();
t.insert_element("xyz", ElementKind(0), Some(Line::from("[ELEM]")));
t.insert_str("longword");
assert_eq!(vec![0..3, 3..11], *t.wrapped_lines(8));
t.move_cursor_up();
assert_eq!(0, t.cursor());
assert_eq!(0, cursor_row(&t, 8));
}
#[test]
fn down_through_element_spanning_rows_reaches_the_end() {
let mut t = TextArea::new();
t.insert_element("abcdefgh", ElementKind(0), None);
t.insert_str("z");
assert_eq!(vec![0..3, 3..6, 6..9], *t.wrapped_lines(3));
t.set_cursor(0);
t.move_cursor_down();
assert_eq!(8, t.cursor());
}
#[test]
fn up_and_down_move_one_row_at_a_time() {
let mut rng = StdRng::seed_from_u64(7);
for _ in 0..500 {
let words = rng.random_range(1..12);
let text = (0..words)
.map(|_| "x".repeat(rng.random_range(1..9)))
.collect::<Vec<_>>()
.join(" ");
let width = rng.random_range(3..12);
let mut t = ta_with(&text);
let rows = t.wrapped_lines(width).len();
t.set_cursor(rng.random_range(0..=text.len()));
let mut row = cursor_row(&t, width);
while row + 1 < rows {
t.move_cursor_down();
assert_eq!(
row + 1,
cursor_row(&t, width),
"down in {text:?} at width {width}"
);
row += 1;
}
while row > 0 {
t.move_cursor_up();
assert_eq!(
row - 1,
cursor_row(&t, width),
"up in {text:?} at width {width}"
);
row -= 1;
}
}
}
+1
View File
@@ -18,6 +18,7 @@ eventsource-stream = { workspace = true }
futures-util = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
reqwest = { workspace = true, features = ["stream"] }
kigi-secrets = { path = "../kigi-secrets" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "time", "sync"] }
+49 -6
View File
@@ -193,6 +193,16 @@ fn extract_context_total(value: &serde_json::Value) -> Option<u32> {
Some(i.saturating_add(o))
}
/// The endpoint as the HTTP client parses it, minus userinfo, sensitive query values and fragment; an unparseable one is not echoed.
fn redacted_endpoint(endpoint: &str) -> String {
match reqwest::Url::parse(endpoint) {
Ok(mut url) => {
kigi_secrets::redact_url(&mut url);
url.to_string()
}
Err(_) => "<unparseable endpoint>".to_owned(),
}
}
/// Record `success=false` + `error` on the active inference span when a stream
/// request fails before any response (transport/connect/TLS errors). Without
/// this the `#[instrument]` span closes with both fields Empty, so an outage
@@ -806,7 +816,7 @@ impl SamplingClient {
let server_message_lower = server_message.to_lowercase();
let mut context_parts = vec![server_message.to_string()];
context_parts.push(format!("\nRequest URL: {}", endpoint));
context_parts.push(format!("\nRequest URL: {}", redacted_endpoint(endpoint)));
// Show headers if error mentions headers
if server_message_lower.contains("header") {
@@ -1006,7 +1016,8 @@ impl SamplingClient {
let endpoint = self.endpoint("chat/completions");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1196,7 +1207,8 @@ impl SamplingClient {
let endpoint = self.endpoint("responses");
let server_message = parse_error_bytes(bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1363,7 +1375,8 @@ impl SamplingClient {
let endpoint = self.endpoint("responses");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
let model_metadata = extract_model_metadata(response.headers());
@@ -1542,7 +1555,8 @@ impl SamplingClient {
let endpoint = self.endpoint("messages");
let server_message = parse_error_bytes(bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1657,7 +1671,8 @@ impl SamplingClient {
let endpoint = self.endpoint("messages");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
let model_metadata = extract_model_metadata(response.headers());
@@ -3048,3 +3063,31 @@ mod tests {
));
}
}
#[cfg(test)]
mod redacted_endpoint_tests {
/// Every spelling the client accepts (LF inside, one slash, no slash) loses its userinfo.
#[test]
fn redacted_endpoint_drops_userinfo_in_every_accepted_spelling() {
for spelling in [
"http://u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http://u:TEST PASSWORD@127.0.0.1:1/v1/chat/completions",
"http://u:TEST_PASSWORD\n@127.0.0.1:1/v1/chat/completions",
"http://u:TEST_PASSWORD\r\n@127.0.0.1:1/v1/chat/completions",
"http:/u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http:u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http:\\u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
] {
let redacted = super::redacted_endpoint(spelling);
assert_eq!(
redacted, "http://127.0.0.1:1/v1/chat/completions",
"{spelling:?}"
);
}
let gateway = super::redacted_endpoint(
"http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=x#frag",
);
assert!(!gateway.contains("TEST_QUERY_TOKEN"), "{gateway}");
assert!(gateway.contains("route=x"), "{gateway}");
}
}
@@ -55,6 +55,15 @@ enum BlockType {
Thinking,
}
/// Executable after a `max_tokens` stop: every call's arguments parse or are empty (zero-arg convention).
fn tool_calls_complete(tool_calls: &[ToolCall]) -> bool {
!tool_calls.is_empty()
&& tool_calls.iter().all(|tc| {
tc.arguments.trim().is_empty()
|| serde_json::from_str::<serde::de::IgnoredAny>(&tc.arguments).is_ok()
})
}
/// Transform a raw Anthropic Messages API stream into a stream of
/// [`SamplingEvent`]s.
///
@@ -454,11 +463,18 @@ pub fn stream_messages<'a>(
}
if final_stop_reason == Some(StopReason::Length) {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
};
return;
if !tool_calls_complete(&assistant_tool_calls) {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
};
return;
}
tracing::info!(
request_id = %request_id,
tool_calls = assistant_tool_calls.len(),
"max_tokens stop after complete tool calls; completing the turn so they run"
);
}
// Build the final response
@@ -653,3 +653,90 @@ async fn pure_cache_hit_with_zero_uncached_still_emits_usage() {
assert_eq!(usage.cached_prompt_tokens, 2500);
assert_eq!(usage.total_tokens, 2501);
}
fn tool_use_start(index: u32, id: &str) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::ToolUse {
id: id.into(),
name: "do_thing".into(),
input: serde_json::json!({}),
},
}
}
fn arg_delta(index: u32, partial_json: &str) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::InputJsonDelta {
partial_json: partial_json.into(),
},
}
}
async fn max_tokens_after(events: Vec<MessageStreamEvent>) -> Vec<SamplingEvent> {
let mut all = vec![Ok(message_start())];
all.extend(events.into_iter().map(Ok));
all.push(Ok(message_delta_with_stop(messages::StopReason::MaxTokens)));
all.push(Ok(MessageStreamEvent::MessageStop));
let raw = stream::iter(all).boxed();
collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await
}
/// Complete tool calls before a max_tokens stop complete the turn as ToolCalls.
#[tokio::test]
async fn max_tokens_after_complete_tool_calls_completes_as_tool_calls() {
let evs = max_tokens_after(vec![
tool_use_start(0, "call_done"),
arg_delta(0, "{\"x\": 1}"),
block_stop(0),
])
.await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
assert_eq!(response.tool_calls().len(), 1);
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "{\"x\": 1}");
}
other => panic!("expected Completed(ToolCalls), got {other:?}"),
}
}
/// A tool_use block with no argument deltas is the zero-argument convention, not truncation.
#[tokio::test]
async fn max_tokens_after_tool_call_without_arguments_completes() {
let evs = max_tokens_after(vec![tool_use_start(0, "call_no_args"), block_stop(0)]).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "");
}
other => panic!("expected Completed(ToolCalls), got {other:?}"),
}
}
/// A call cut mid-arguments is truncation: the turn fails so the caller can compact.
#[tokio::test]
async fn max_tokens_with_truncated_tool_arguments_fails() {
let evs = max_tokens_after(vec![
tool_use_start(0, "call_ok"),
arg_delta(0, "{}"),
block_stop(0),
tool_use_start(1, "call_cut"),
arg_delta(1, "{\"x\": \"trunc"),
block_stop(1),
])
.await;
assert!(
!evs.iter()
.any(|e| matches!(e, SamplingEvent::Completed { .. })),
"a cut tool call must not complete: {evs:?}"
);
match evs.last().unwrap() {
SamplingEvent::Failed { error, .. } => assert_eq!(
error.kind,
crate::events::SamplingErrorKind::MaxTokensTruncation
),
other => panic!("expected Failed(MaxTokensTruncation), got {other:?}"),
}
}
@@ -897,7 +897,7 @@ impl CompactionsRemaining {
}
/// Reasoning effort level. `None`/`Minimal` are omitted on the Anthropic Messages API.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
None,
@@ -982,6 +982,16 @@ impl std::str::FromStr for ReasoningEffort {
}
}
impl<'de> serde::Deserialize<'de> for ReasoningEffort {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
/// Canonical wire parse; remapped menu ids need a model catalog.
pub fn parse_canonical_effort_token(token: &str) -> Option<ReasoningEffort> {
token.parse().ok()
@@ -1146,7 +1156,7 @@ pub const ANTHROPIC_OAUTH_BETA: &str = "claude-code-20250219,oauth-2025-04-20";
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
/// official Claude Code CLI). OAuth-gated: unrelated to the default kigi UA.
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75";
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.280";
/// System-prompt prefix REQUIRED on the Claude-Code OAuth Messages path: the
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
@@ -1192,7 +1202,7 @@ pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
/// openai-codex-gated). The Codex backend does not strictly validate the UA
/// string (Pi ships its own and it works), so this is a stable best-effort
/// identity, not a pinned build.
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.104.0";
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.156.0";
/// JWT payload claim namespace carrying the ChatGPT account id.
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
@@ -1849,6 +1859,24 @@ mod tests {
assert!(serde_json::from_str::<ReasoningEffort>("\"BOGUS\"").is_err());
}
#[test]
fn reasoning_effort_deserializes_mixed_case() {
for (token, want) in [
("\"High\"", ReasoningEffort::High),
("\"XHIGH\"", ReasoningEffort::Xhigh),
("\"mAx\"", ReasoningEffort::Max),
] {
let got: ReasoningEffort = serde_json::from_str(token).unwrap();
assert_eq!(got, want, "deserialize {token}");
}
let err = serde_json::from_str::<ReasoningEffort>("\"Highest\"").unwrap_err();
assert!(
err.to_string().contains("invalid reasoning effort"),
"{err}"
);
assert!(serde_json::from_str::<ReasoningEffort>("7").is_err());
}
#[test]
fn reasoning_effort_from_str_max_and_xhigh_are_distinct() {
assert_eq!(
+1
View File
@@ -51,6 +51,7 @@ enforce = []
[dev-dependencies]
serial_test = { workspace = true }
tempfile = { workspace = true }
# Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches
# EXACTLY the set globset (the Linux backend) matches — the cross-platform parity
# guard for the glob dialect.
+210 -74
View File
@@ -1,91 +1,130 @@
//! Per-child seccomp network filter. No-op on non-Linux.
/// Install seccomp BPF filter blocking network syscalls.
#[cfg(target_os = "linux")]
mod bpf {
use libc::sock_filter;
pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
pub(super) const EPERM_VAL: u32 = 1;
#[cfg(target_arch = "x86_64")]
pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000;
/// `seccomp_data` field offsets.
pub(super) const OFF_NR: u32 = 0;
pub(super) const OFF_ARCH: u32 = 4;
#[cfg(target_arch = "x86_64")]
pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64
#[cfg(target_arch = "aarch64")]
pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
pub(super) const EXPECTED_ARCH: u32 = 0;
pub(super) fn stmt(code: u32, k: u32) -> sock_filter {
sock_filter {
code: code as u16,
jt: 0,
jf: 0,
k,
}
}
pub(super) fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter {
sock_filter {
code: code as u16,
jt,
jf,
k,
}
}
/// Reject a foreign-arch (and, on x86_64, x32) syscall number before
/// matching `nr`, so a syscall reached through another table cannot
/// alias past the deny list.
pub(super) fn push_arch_nr_gate(f: &mut Vec<sock_filter>) {
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W};
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH));
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR));
#[cfg(target_arch = "x86_64")]
{
f.push(jump(
BPF_JMP | libc::BPF_JSET | BPF_K,
X32_SYSCALL_BIT,
0,
1,
));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
}
}
}
/// Syscalls that open or send on a socket, including the `io_uring` and
/// `sendmmsg` paths that bypass the classic entry points.
#[cfg(target_os = "linux")]
fn child_network_blocked_syscalls() -> [u32; 11] {
use libc::{
SYS_accept, SYS_accept4, SYS_bind, SYS_connect, SYS_io_uring_enter, SYS_io_uring_register,
SYS_io_uring_setup, SYS_listen, SYS_sendmmsg, SYS_sendmsg, SYS_sendto,
};
[
SYS_connect as u32,
SYS_bind as u32,
SYS_sendto as u32,
SYS_sendmsg as u32,
SYS_sendmmsg as u32,
SYS_listen as u32,
SYS_accept as u32,
SYS_accept4 as u32,
SYS_io_uring_setup as u32,
SYS_io_uring_enter as u32,
SYS_io_uring_register as u32,
]
}
#[cfg(target_os = "linux")]
fn build_child_network_filter() -> Vec<libc::sock_filter> {
use bpf::{EPERM_VAL, SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, jump, stmt};
use libc::{BPF_JEQ, BPF_JMP, BPF_K, BPF_RET};
let blocked = child_network_blocked_syscalls();
let mut f = Vec::with_capacity(blocked.len() + 10);
bpf::push_arch_nr_gate(&mut f);
let n = blocked.len();
for (i, &sys) in blocked.iter().enumerate() {
let remaining = n - i - 1;
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, remaining as u8 + 1, 0));
}
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
f
}
/// Built once: `pre_exec` runs after `fork`, where allocating is unsafe.
#[cfg(target_os = "linux")]
pub fn prebuilt_child_network_filter() -> &'static [libc::sock_filter] {
static FILTER: std::sync::OnceLock<Vec<libc::sock_filter>> = std::sync::OnceLock::new();
FILTER.get_or_init(build_child_network_filter)
}
/// Install the seccomp BPF filter blocking network syscalls.
///
/// # Safety
///
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
#[cfg(target_os = "linux")]
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
use libc::{
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, PR_SET_NO_NEW_PRIVS,
PR_SET_SECCOMP, SECCOMP_MODE_FILTER, SYS_accept, SYS_accept4, SYS_bind, SYS_connect,
SYS_listen, SYS_sendmsg, SYS_sendto, prctl, sock_filter, sock_fprog,
};
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
// libc::EPERM
const EPERM_VAL: u32 = 1;
macro_rules! bpf_stmt {
($code:expr, $k:expr) => {
sock_filter {
code: $code as u16,
jt: 0,
jf: 0,
k: $k as u32,
}
};
}
macro_rules! bpf_jump {
($code:expr, $k:expr, $jt:expr, $jf:expr) => {
sock_filter {
code: $code as u16,
jt: $jt,
jf: $jf,
k: $k as u32,
}
};
}
// seccomp_data.nr offset
const NR_OFFSET: u32 = 0;
let blocked_syscalls: &[i64] = &[
SYS_connect,
SYS_bind,
SYS_sendto,
SYS_sendmsg,
SYS_listen,
SYS_accept,
SYS_accept4,
];
let mut filter: Vec<sock_filter> = Vec::new();
let total_checks = blocked_syscalls.len();
filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET));
for (i, &syscall) in blocked_syscalls.iter().enumerate() {
let remaining = total_checks - i - 1;
filter.push(bpf_jump!(
BPF_JMP | BPF_JEQ | BPF_K,
syscall,
// match: jump to ERRNO
remaining as u8 + 1,
// no match: check next
0
));
}
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
pub unsafe fn install_child_network_filter(filter: &[libc::sock_filter]) -> std::io::Result<()> {
use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog};
let prog = sock_fprog {
len: filter.len() as u16,
filter: filter.as_mut_ptr(),
filter: filter.as_ptr().cast_mut(),
};
// Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
// SAFETY: prog is a valid sock_fprog pointing to a filter the kernel copies.
if unsafe {
prctl(
PR_SET_SECCOMP,
@@ -98,7 +137,6 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
{
return Err(std::io::Error::last_os_error());
}
Ok(())
}
@@ -109,3 +147,101 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
Ok(())
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::bpf::*;
use super::{build_child_network_filter, child_network_blocked_syscalls};
use libc::sock_filter;
/// Minimal classic-BPF interpreter over synthetic `seccomp_data` fields.
fn eval(filter: &[sock_filter], arch: u32, nr: u32) -> u32 {
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W};
let mut pc = 0usize;
let mut a = 0u32;
for _ in 0..filter.len().saturating_mul(2) {
let insn = &filter[pc];
let op = insn.code as u32;
if op == (BPF_LD | BPF_W | BPF_ABS) {
a = match insn.k {
OFF_NR => nr,
OFF_ARCH => arch,
_ => 0,
};
pc += 1;
} else if op == (BPF_JMP | BPF_JEQ | BPF_K) {
pc = if a == insn.k {
pc + 1 + insn.jt as usize
} else {
pc + 1 + insn.jf as usize
};
} else if op == (BPF_JMP | BPF_JSET | BPF_K) {
pc = if a & insn.k != 0 {
pc + 1 + insn.jt as usize
} else {
pc + 1 + insn.jf as usize
};
} else if op == (BPF_RET | BPF_K) {
return insn.k;
} else {
panic!("unsupported opcode {:#x} at {pc}", insn.code);
}
assert!(pc < filter.len(), "pc out of range");
}
panic!("filter did not RET");
}
fn is_allow(r: u32) -> bool {
r == SECCOMP_RET_ALLOW
}
fn is_eperm(r: u32) -> bool {
r == (SECCOMP_RET_ERRNO | EPERM_VAL)
}
#[test]
fn blocks_every_network_syscall_and_allows_read_and_socket() {
let f = build_child_network_filter();
for sys in child_network_blocked_syscalls() {
assert!(
is_eperm(eval(&f, EXPECTED_ARCH, sys)),
"syscall {sys} must be EPERM"
);
}
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_read as u32)));
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_socket as u32)));
}
#[test]
fn covers_io_uring_and_sendmmsg() {
let blocked = child_network_blocked_syscalls();
for sys in [
libc::SYS_sendmmsg,
libc::SYS_io_uring_setup,
libc::SYS_io_uring_enter,
libc::SYS_io_uring_register,
] {
assert!(blocked.contains(&(sys as u32)), "{sys} missing");
}
}
#[test]
fn wrong_arch_and_x32_are_denied_before_the_nr_match() {
let f = build_child_network_filter();
assert!(is_eperm(eval(&f, 0xdead_beef, libc::SYS_read as u32)));
#[cfg(target_arch = "x86_64")]
assert!(is_eperm(eval(
&f,
EXPECTED_ARCH,
(libc::SYS_read as u32) | X32_SYSCALL_BIT
)));
}
#[test]
fn prebuilt_filter_is_shared() {
let a = super::prebuilt_child_network_filter();
let b = super::prebuilt_child_network_filter();
assert!(std::ptr::eq(a, b));
assert_eq!(a.len(), build_child_network_filter().len());
}
}
+116 -4
View File
@@ -333,6 +333,27 @@ fn is_devbox_based(profile: &ProfileName, config: &SandboxConfig) -> bool {
_ => false,
}
}
/// Whether an existing `/data` needs the devbox bwrap read-only bind; a filesystem error other than NotFound conservatively requires it.
#[cfg(target_os = "linux")]
pub fn requires_data_write_deny(profile: &ProfileName, workspace: &Path) -> bool {
requires_data_write_deny_for(
profile,
&profiles::load_sandbox_config(workspace),
data_path_requires_bind(Path::new("/data")),
)
}
#[cfg(target_os = "linux")]
fn requires_data_write_deny_for(
profile: &ProfileName,
config: &SandboxConfig,
is_data_present: bool,
) -> bool {
is_devbox_based(profile, config) && is_data_present
}
#[cfg(target_os = "linux")]
fn data_path_requires_bind(path: &Path) -> bool {
path.try_exists().unwrap_or(true)
}
/// Whether kernel read-deny enforcement is required. The single source of truth
/// for this classification so callers (e.g. the shell's fail-closed startup path)
/// cannot drift and silently fail open.
@@ -360,6 +381,23 @@ pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool {
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
false
}
/// Whether the resolved profile leaves `~/.kigi` read-only outside its
/// state dirs (strict and every profile extending it), so files first use
/// would write there must be minted before enforcement.
#[cfg(all(feature = "enforce", unix))]
pub fn restricts_home_writes(profile: &ProfileName, workspace: &Path) -> bool {
if *profile == ProfileName::Off {
return false;
}
let config = profiles::load_sandbox_config(workspace);
profile
.resolve_profile(workspace, &config)
.is_ok_and(|resolved| !resolved.read_write.contains(&paths::kigi_home()))
}
#[cfg(not(all(feature = "enforce", unix)))]
pub fn restricts_home_writes(_profile: &ProfileName, _workspace: &Path) -> bool {
false
}
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
@@ -382,7 +420,11 @@ struct BwrapDenyPlan {
#[cfg(all(feature = "enforce", target_os = "linux"))]
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
let config = profiles::load_sandbox_config(workspace);
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
let deny_write: Vec<String> = if requires_data_write_deny_for(
profile,
&config,
data_path_requires_bind(Path::new("/data")),
) {
vec!["/data".to_string()]
} else {
Vec::new()
@@ -424,7 +466,11 @@ fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyP
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
let config = profiles::load_sandbox_config(workspace);
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
let deny_write: Vec<String> = if requires_data_write_deny_for(
profile,
&config,
data_path_requires_bind(Path::new("/data")),
) {
vec!["/data".to_string()]
} else {
Vec::new()
@@ -595,6 +641,43 @@ mod tests {
);
}
#[test]
#[cfg(target_os = "linux")]
fn data_write_deny_requires_devbox_and_a_present_data_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".kigi")).unwrap();
std::fs::write(
tmp.path().join(".kigi/sandbox.toml"),
"[profiles.devext]\nextends = \"devbox\"\n\n[profiles.plain]\nextends = \"workspace\"\n",
)
.unwrap();
let config = profiles::load_sandbox_config(tmp.path());
assert!(requires_data_write_deny_for(
&ProfileName::Devbox,
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Devbox,
&config,
false
));
assert!(requires_data_write_deny_for(
&ProfileName::Custom("devext".into()),
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Custom("plain".into()),
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Workspace,
&config,
true
));
}
#[test]
fn configured_profile_is_recorded() {
set_configured_profile("read-only");
assert_eq!(configured_profile_name(), Some("read-only"));
@@ -700,10 +783,11 @@ mod tests {
"devbox-empty",
"[profiles.devempty]\nextends = \"devbox\"\n",
);
assert!(
assert_eq!(
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
.is_some(),
"devbox-extending custom must compose the /data write-deny re-exec"
Path::new("/data").exists(),
"devbox-extending custom composes the /data write-deny re-exec exactly when /data exists"
);
let _ = std::fs::remove_dir_all(&ws_empty);
let ws_ws = temp_workspace_with_sandbox_toml(
@@ -717,3 +801,31 @@ mod tests {
let _ = std::fs::remove_dir_all(&ws_ws);
}
}
#[cfg(all(test, feature = "enforce", unix))]
mod home_write_tests {
use super::{ProfileName, restricts_home_writes};
#[test]
fn strict_restricts_home_writes_and_workspace_does_not() {
let workspace = std::env::temp_dir();
assert!(restricts_home_writes(&ProfileName::Strict, &workspace));
assert!(!restricts_home_writes(&ProfileName::Workspace, &workspace));
assert!(!restricts_home_writes(&ProfileName::ReadOnly, &workspace));
assert!(!restricts_home_writes(&ProfileName::Off, &workspace));
}
#[test]
fn custom_profile_extending_strict_inherits_the_restriction() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join(".kigi");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
project.join("sandbox.toml"),
"[profiles.audit-home-write-test]\nextends = \"strict\"\n",
)
.unwrap();
let custom = ProfileName::Custom("audit-home-write-test".to_string());
assert!(restricts_home_writes(&custom, tmp.path()));
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
//! Sandbox event logger.
//!
//! Events (profile applied, violations, bypasses) are buffered in memory and
//! flushed as JSONL to `~/.kigi/sandbox-events.jsonl`.
//! flushed as JSONL to `~/.kigi/sessions/sandbox-events.jsonl`.
use std::path::PathBuf;
use std::sync::Mutex;
@@ -87,8 +87,12 @@ impl SandboxLogger {
Ok(())
}
/// Under `sessions/`: the only home subtree a strict sandbox leaves
/// writable, and this flush runs after enforcement.
fn log_file_path() -> PathBuf {
kigi_config::kigi_home().join("sandbox-events.jsonl")
kigi_config::kigi_home()
.join("sessions")
.join("sandbox-events.jsonl")
}
}
+31
View File
@@ -96,6 +96,37 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
paths
}
/// Home subtrees the process keeps writing after enforcement: session and
/// worktree state, memory, diagnostics. Landlock cannot carve a child out of
/// a write grant, so the rest of `~/.kigi` (`config.toml`, `auth.json`,
/// `sandbox.toml`, `hooks/`, `bin/`, `vendor/`, `plugin-data/`) stays
/// read-only and a sandboxed child cannot arm the next launch.
#[cfg(all(feature = "enforce", unix))]
const HOME_STATE_DIRS: &[&str] = &[
"run",
"sessions",
"worktrees",
"worktree_pool",
"tasks",
"memory",
"logs",
"debug",
"crash",
"memtrace",
"trace-exports",
];
/// Writable paths for the strict profile: the workspace, the home state
/// subtrees, and temp.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn essential_writable_paths_strict(workspace: &Path) -> Vec<PathBuf> {
let home = kigi_home();
let mut paths = vec![workspace.to_path_buf()];
paths.extend(HOME_STATE_DIRS.iter().map(|dir| home.join(dir)));
paths.extend(temp_writable_paths());
paths
}
/// Writable directory paths for the read-only profile (minimal: just ~/.kigi + temp).
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
#[cfg(all(feature = "enforce", unix))]
+113 -6
View File
@@ -18,6 +18,7 @@ use crate::paths::kigi_home;
#[cfg(all(feature = "enforce", unix))]
use crate::paths::{
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
essential_writable_paths_strict,
};
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
@@ -186,6 +187,32 @@ fn load_config_file(path: &Path) -> Option<SandboxConfig> {
#[cfg(all(feature = "enforce", unix))]
impl ProfileName {
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
/// The directory a `read_write` entry really grants. A symlink under
/// `~/.kigi` is followed only to the default `~/.kigi/sessions` (the
/// `KIGI_SHARE_DIR` override case); any other target would widen the
/// grant to wherever the link points. Missing paths are created.
fn read_write_grant_path(path: &Path, home: &Path) -> Option<PathBuf> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
if path == home || !path.starts_with(home) {
return Some(path.to_path_buf());
}
let real = dunce::canonicalize(path).ok()?;
let default_sessions = kigi_config::default_kigi_home().join("sessions");
(real.is_dir()
&& path.file_name() == Some(std::ffi::OsStr::new("sessions"))
&& real == default_sessions)
.then_some(real)
}
Ok(_) => Some(path.to_path_buf()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(path).ok()?;
Some(path.to_path_buf())
}
Err(_) => None,
}
}
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
let config = load_sandbox_config(workspace);
self.to_capability_set_with_config(workspace, &config)
@@ -231,13 +258,14 @@ impl ProfileName {
// apply time (it opens an O_PATH fd), but new files within it can
// be created freely after the sandbox is applied. Pre-create
// directories like ~/.kigi/ that may not exist on first run.
let home = kigi_home();
for path in &profile.read_write {
if !path.exists() && std::fs::create_dir_all(path).is_err() {
tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping");
let Some(grant) = Self::read_write_grant_path(path, &home) else {
tracing::warn!(path = ?path, "read_write grant skipped");
continue;
}
let Some(path_str) = path.to_str() else {
tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path");
};
let Some(path_str) = grant.to_str() else {
tracing::warn!(path = ?grant, "Skipping non-UTF8 read_write path");
continue;
};
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
@@ -380,12 +408,15 @@ impl ProfileName {
.chain(std::iter::once(home.join("Library")))
.filter(|p| p.exists())
.chain(std::iter::once(workspace.to_path_buf()))
// Read-only: config, auth and global hooks are read after
// enforcement; only sessions/ below stays writable.
.chain(std::iter::once(kigi_home()))
.collect();
Ok(SandboxProfile {
name: "strict".to_string(),
read_only: system_read,
read_write: essential_writable_paths(workspace),
read_write: essential_writable_paths_strict(workspace),
deny: vec![],
default_read: false,
restrict_network: true,
@@ -544,6 +575,82 @@ mod tests {
}
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn strict_read_write_is_workspace_home_state_and_temp() {
let workspace = std::env::temp_dir();
let profile = ProfileName::Strict
.resolve_profile(&workspace, &SandboxConfig::default())
.expect("strict resolves");
let home = kigi_home();
assert_eq!(profile.read_write[0], workspace);
for dir in ["sessions", "worktrees", "memory", "logs", "debug"] {
assert!(
profile.read_write.contains(&home.join(dir)),
"{dir} must stay writable: {:?}",
profile.read_write
);
}
for denied in [
home.clone(),
home.join("hooks"),
home.join("vendor"),
home.join("bin"),
] {
assert!(
!profile.read_write.contains(&denied),
"strict must not grant {}: {:?}",
denied.display(),
profile.read_write
);
}
assert!(
profile.read_only.contains(&home),
"config/auth/hooks are read after enforcement: {:?}",
profile.read_only
);
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn read_write_grant_follows_real_dirs_and_creates_missing_ones() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(home.join("sessions")).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
Some(home.join("sessions"))
);
let missing = home.join("fresh");
assert_eq!(
ProfileName::read_write_grant_path(&missing, &home),
Some(missing.clone())
);
assert!(missing.is_dir());
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn read_write_grant_refuses_a_home_symlink_pointing_elsewhere() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, home.join("sessions")).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
None
);
// A symlinked workspace outside the home is the user's own choice.
let ws_link = tmp.path().join("ws");
std::os::unix::fs::symlink(&outside, &ws_link).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&ws_link, &home),
Some(ws_link.clone())
);
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn base_profile_capability_set_builds() {
+12 -175
View File
@@ -544,13 +544,16 @@ mod attachments_protocol {
}
}
#[cfg(any(target_os = "macos", test))]
mod osascript_probe;
// macOS: subprocess-based clipboard (no AppKit linkage)
#[cfg(target_os = "macos")]
mod platform {
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
use super::osascript_probe::{ProbeTemps, osascript_attachments, read_probe_raster};
use super::{ClipboardAttachments, ImageData};
// Fast pasteboard probes (NSPasteboard via lazy dlopen)
@@ -759,153 +762,15 @@ mod platform {
Ok(output.stdout)
}
fn attachments_probe_temp_paths() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf)
{
let temp_dir = std::env::temp_dir();
(
temp_dir.join("kigi-clipboard-probe.png"),
temp_dir.join("kigi-clipboard-probe.tiff"),
temp_dir.join("kigi-clipboard-probe.jpg"),
)
}
fn read_clipboard_image_from_class(
class: &str,
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) -> anyhow::Result<Option<ImageData>> {
let (temp_path, mime) = match class {
"PNGf" => (path_png, "image/png"),
"TIFF" => (path_tiff, "image/tiff"),
"JPEG" => (path_jpg, "image/jpeg"),
_ => return Ok(None),
};
let data = match std::fs::read(temp_path) {
Ok(bytes) if !bytes.is_empty() => bytes,
Ok(_) => {
let _ = std::fs::remove_file(temp_path);
return Ok(None);
}
Err(e) => {
let _ = std::fs::remove_file(temp_path);
return Err(anyhow::anyhow!("failed to read clipboard temp file: {e}"));
}
};
let _ = std::fs::remove_file(temp_path);
Ok(Some(ImageData {
data,
mime_type: mime.to_owned(),
}))
}
fn remove_attachment_probe_temps(
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) {
let _ = std::fs::remove_file(path_png);
let _ = std::fs::remove_file(path_tiff);
let _ = std::fs::remove_file(path_jpg);
}
fn attachments_osascript(
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) -> String {
format!(
"set furlOut to \"none\"\n\
try\n\
set urlList to the clipboard as list\n\
set out to \"\"\n\
repeat with u in urlList\n\
try\n\
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
set out to out & POSIX path of itemRef & \"\\n\"\n\
end try\n\
end repeat\n\
if out is not \"\" then\n\
set furlOut to out\n\
else\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end if\n\
on error\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end try\n\
set imageOut to \"NONE\"\n\
if furlOut is \"none\" then\n\
try\n\
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
set filePath to POSIX file \"{png}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"PNGf\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
set filePath to POSIX file \"{tiff}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"TIFF\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
set filePath to POSIX file \"{jpg}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"JPEG\"\n\
on error\n\
end try\n\
end try\n\
end try\n\
end if\n\
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
png = path_png.display(),
tiff = path_tiff.display(),
jpg = path_jpg.display(),
furl_marker = FURL_MARKER,
image_marker = IMAGE_MARKER,
)
}
fn run_attachments_osascript() -> anyhow::Result<String> {
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
let script = attachments_osascript(&path_png, &path_tiff, &path_jpg);
fn run_osascript(script: &str) -> anyhow::Result<Vec<u8>> {
let mut cmd = Command::new("osascript");
cmd.arg("-e")
.arg(&script)
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
kigi_tools::util::detach_std_command(&mut cmd);
let stdout = match checked_command_stdout("osascript", cmd.output()) {
Ok(stdout) => stdout,
Err(error) => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Err(error);
}
};
Ok(String::from_utf8_lossy(&stdout).into_owned())
checked_command_stdout("osascript", cmd.output())
}
/// Unified furl-then-image pasteboard probe.
@@ -923,28 +788,7 @@ mod platform {
image: Some(image),
});
}
let raw = run_attachments_osascript()?;
if raw.trim().is_empty() {
return Ok(ClipboardAttachments::default());
}
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
let (file_urls, image_class) = parse_attachments_output(&raw);
let image = if file_urls.is_some() {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
None
} else {
match image_class {
Some(class) => {
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)?
}
None => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
None
}
}
};
Ok(ClipboardAttachments { file_urls, image })
osascript_attachments(ProbeTemps::new(), run_osascript)
}
/// Read text via `pbpaste -Prefer txt`.
@@ -1020,8 +864,8 @@ mod platform {
return Ok(Some(image));
}
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
let temps = ProbeTemps::new()?;
let (path_png, path_tiff, path_jpg) = temps.paths();
// Image-only AppleScript (ImageOnly paste route). Unicode guillemets
// (\u{AB}/\u{BB}) are required for `«class …»` in `osascript -e`.
@@ -1069,20 +913,13 @@ mod platform {
.stdout(Stdio::piped())
.stderr(Stdio::null());
kigi_tools::util::detach_std_command(&mut cmd);
let stdout = match checked_command_stdout("osascript", cmd.output()) {
Ok(stdout) => stdout,
Err(error) => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Err(error);
}
};
let stdout = checked_command_stdout("osascript", cmd.output())?;
let class = String::from_utf8_lossy(&stdout);
let class = class.trim();
if class == "none" {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Ok(None);
}
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)
read_probe_raster(class, &temps)
}
/// Read file URLs from the macOS pasteboard via `osascript`.
@@ -0,0 +1,246 @@
//! The macOS `osascript` attachment probe: scratch files, script text, and the read flow.
use std::path::PathBuf;
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
use super::{ClipboardAttachments, ImageData};
/// Private 0700 dir, removed on drop: a shared path let a concurrent kigi swap the raster.
pub(crate) struct ProbeTemps(tempfile::TempDir);
impl ProbeTemps {
pub(crate) fn new() -> anyhow::Result<Self> {
let mut builder = tempfile::Builder::new();
builder.prefix("kigi-clipboard-probe-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
builder.permissions(std::fs::Permissions::from_mode(0o700));
}
builder
.tempdir()
.map(Self)
.map_err(|e| anyhow::anyhow!("failed to create clipboard probe dir: {e}"))
}
/// PNG, TIFF, JPEG: the AppleScript writes the first class that coerces.
pub(crate) fn paths(&self) -> (PathBuf, PathBuf, PathBuf) {
(
self.0.path().join("probe.png"),
self.0.path().join("probe.tiff"),
self.0.path().join("probe.jpg"),
)
}
}
/// File URLs first, a raster only when none are present; without `temps` the raster half is skipped.
pub(crate) fn attachments_osascript(temps: Option<&ProbeTemps>) -> String {
let image_probe = temps.map_or_else(String::new, raster_half);
format!(
"set furlOut to \"none\"\n\
try\n\
set urlList to the clipboard as list\n\
set out to \"\"\n\
repeat with u in urlList\n\
try\n\
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
set out to out & POSIX path of itemRef & \"\\n\"\n\
end try\n\
end repeat\n\
if out is not \"\" then\n\
set furlOut to out\n\
else\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end if\n\
on error\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end try\n\
set imageOut to \"NONE\"\n\
{image_probe}\
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
furl_marker = FURL_MARKER,
image_marker = IMAGE_MARKER,
)
}
fn raster_half(temps: &ProbeTemps) -> String {
let (path_png, path_tiff, path_jpg) = temps.paths();
format!(
"if furlOut is \"none\" then\n\
try\n\
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
set filePath to POSIX file \"{png}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"PNGf\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
set filePath to POSIX file \"{tiff}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"TIFF\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
set filePath to POSIX file \"{jpg}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"JPEG\"\n\
on error\n\
end try\n\
end try\n\
end try\n\
end if\n",
png = path_png.display(),
tiff = path_tiff.display(),
jpg = path_jpg.display(),
)
}
/// The class on stdout picks the file and MIME; an empty file means no raster.
pub(crate) fn read_probe_raster(
class: &str,
temps: &ProbeTemps,
) -> anyhow::Result<Option<ImageData>> {
let (path_png, path_tiff, path_jpg) = temps.paths();
let (temp_path, mime) = match class {
"PNGf" => (path_png, "image/png"),
"TIFF" => (path_tiff, "image/tiff"),
"JPEG" => (path_jpg, "image/jpeg"),
_ => return Ok(None),
};
let data = std::fs::read(&temp_path)
.map_err(|e| anyhow::anyhow!("failed to read clipboard temp file: {e}"))?;
if data.is_empty() {
return Ok(None);
}
Ok(Some(ImageData {
data,
mime_type: mime.to_owned(),
}))
}
/// `run` executes a script and returns its stdout; a file paste needs no scratch dir.
pub(crate) fn osascript_attachments(
temps: anyhow::Result<ProbeTemps>,
run: impl FnOnce(&str) -> anyhow::Result<Vec<u8>>,
) -> anyhow::Result<ClipboardAttachments> {
if let Err(error) = &temps {
tracing::warn!(%error, "clipboard probe reads file URLs only");
}
let stdout = run(&attachments_osascript(temps.as_ref().ok()))?;
let (file_urls, image_class) = parse_attachments_output(&String::from_utf8_lossy(&stdout));
if file_urls.is_some() {
return Ok(ClipboardAttachments {
file_urls,
image: None,
});
}
let temps = temps?;
let image = match image_class {
Some(class) => read_probe_raster(class, &temps)?,
None => None,
};
Ok(ClipboardAttachments {
file_urls: None,
image,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn attachments_stdout(furl: &str, image: &str) -> Vec<u8> {
format!("{FURL_MARKER}\n{furl}\n{IMAGE_MARKER}\nIMAGE:{image}").into_bytes()
}
#[test]
fn two_probes_never_share_a_path() {
let first = ProbeTemps::new().unwrap();
let second = ProbeTemps::new().unwrap();
assert_ne!(first.paths().0, second.paths().0);
}
#[test]
fn scratch_dir_goes_away_with_the_probe() {
let temps = ProbeTemps::new().unwrap();
let (png, tiff, jpg) = temps.paths();
let dir = png.parent().unwrap().to_path_buf();
assert_eq!(tiff.parent().unwrap(), dir);
assert_eq!(jpg.parent().unwrap(), dir);
std::fs::write(&png, b"raster").unwrap();
drop(temps);
assert!(!dir.exists(), "{} must be removed", dir.display());
}
#[cfg(unix)]
#[test]
fn scratch_dir_is_private() {
use std::os::unix::fs::PermissionsExt;
let temps = ProbeTemps::new().unwrap();
let dir = temps.paths().0.parent().unwrap().to_path_buf();
let mode = std::fs::metadata(dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn reported_class_is_read_from_this_probes_file() {
let temps = ProbeTemps::new().unwrap();
let (_png, tiff, _jpg) = temps.paths();
std::fs::write(&tiff, b"MM\x00\x2a").unwrap();
let script_names_the_file = tiff.display().to_string();
let read = osascript_attachments(Ok(temps), |script| {
assert!(script.contains(&script_names_the_file), "{script}");
Ok(attachments_stdout("none", "TIFF"))
})
.unwrap();
let image = read.image.expect("raster attached");
assert_eq!(image.mime_type, "image/tiff");
assert_eq!(image.data, b"MM\x00\x2a".to_vec());
assert_eq!(read.file_urls, None);
}
#[test]
fn file_urls_survive_a_missing_scratch_dir() {
let read = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |script| {
assert!(
!script.contains("open for access"),
"raster half must be skipped"
);
Ok(attachments_stdout("/tmp/a.txt\n", "NONE"))
})
.unwrap();
assert_eq!(read.file_urls.as_deref(), Some("/tmp/a.txt"));
assert_eq!(read.image, None);
}
#[test]
fn no_scratch_dir_and_no_file_url_is_an_error_not_an_empty_board() {
let err = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |_| {
Ok(attachments_stdout("none", "NONE"))
})
.unwrap_err();
assert!(err.to_string().contains("no scratch dir"), "{err}");
}
}
+1
View File
@@ -150,6 +150,7 @@ fs2 = { workspace = true }
zstd = { workspace = true }
tar = { workspace = true }
url = { workspace = true }
kigi-secrets = { path = "../kigi-secrets" }
walkdir = { workspace = true }
tdigests = "1.0"
semver = { workspace = true }
+2 -2
View File
@@ -2101,7 +2101,7 @@ kigi --sandbox strict
| `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox |
| `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development |
| `read-only` | Everywhere | `~/.kigi/` only | Blocked | Exploration, code review |
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` | Blocked | Untrusted code |
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` state dirs | Blocked | Untrusted code |
Sensitive paths (`~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kigi/auth/`) are always
write-protected regardless of profile.
@@ -2160,7 +2160,7 @@ model cannot convince the agent to relax restrictions at runtime.
### Event Logging
Sandbox events (profile applied, violations) are logged to `~/.kigi/sandbox-events.jsonl`
Sandbox events (profile applied, violations) are logged to `~/.kigi/sessions/sandbox-events.jsonl`
for telemetry and debugging.
---
+55 -2
View File
@@ -2601,6 +2601,7 @@ pub(crate) fn resolve_model_list(
}
resolved = prefetched;
}
let mut explicit_api_backend_keys = std::collections::HashSet::new();
for (key, model_override) in &cfg.config_models {
let had_base = resolved.contains_key(key);
let base = resolved.shift_remove(key);
@@ -2617,6 +2618,9 @@ pub(crate) fn resolve_model_list(
);
}
}
if model_override.api_backend.is_some() {
explicit_api_backend_keys.insert(key.as_str());
}
let entry = model_override.apply(key, base, &cfg.endpoints);
tracing::debug!(
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
@@ -2638,7 +2642,7 @@ pub(crate) fn resolve_model_list(
)
})
.collect();
for entry in resolved.values_mut() {
for (key, entry) in resolved.iter_mut() {
if let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) {
if entry.info.context_window.get() == default_cw {
tracing::debug!(
@@ -2648,7 +2652,9 @@ pub(crate) fn resolve_model_list(
);
entry.info.context_window = *donor_cw;
}
if entry.info.api_backend == ApiBackend::default()
// An explicit `[model.X] api_backend` wins even when it is the default.
if !explicit_api_backend_keys.contains(key.as_str())
&& entry.info.api_backend == ApiBackend::default()
&& *donor_backend != ApiBackend::default()
{
entry.info.api_backend.clone_from(donor_backend);
@@ -6701,7 +6707,10 @@ reasoning_effort = "low"
assert_eq!(model.info.base_url, "https://inference.example.com/v1");
}
#[test]
#[serial]
fn e2e_default_model_with_session_routes_to_proxy() {
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
let (_, models) = resolve_models_from_toml("", None);
let model = models
.get(BUNDLED_DEFAULT_KEY)
@@ -6833,7 +6842,10 @@ reasoning_effort = "low"
);
}
#[test]
#[serial]
fn e2e_duplicate_model_field_both_entries_survive() {
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
let dm = crate::models::default_model();
let (_, models) = resolve_models_from_toml(
&format!(
@@ -9067,6 +9079,47 @@ default = "kigi-4.5"
sibling 'kigi-build' (same model slug), not stay at {default_cw}"
);
}
/// An explicit `[model.X] api_backend` survives a same-slug sibling even when it is the default.
#[test]
fn slug_propagation_keeps_an_explicit_default_api_backend() {
// Only kigi-messages has a non-default context window: the slug's single donor.
let default_cw = DEFAULT_CONTEXT_WINDOW;
let raw: toml::Value = toml::from_str(&format!(
r#"
[model.kigi-messages]
model = "kigi-4.5"
context_window = 500000
base_url = "https://test.example.com/v1"
api_backend = "messages"
[model.kigi-chat]
model = "kigi-4.5"
context_window = {default_cw}
base_url = "https://test.example.com/v1"
api_backend = "chat_completions"
[model.kigi-plain]
model = "kigi-4.5"
context_window = {default_cw}
base_url = "https://test.example.com/v1"
"#
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None, &Default::default());
let backend = |key: &str| resolved.get(key).expect(key).info.api_backend.clone();
assert_eq!(
backend("kigi-chat"),
ApiBackend::ChatCompletions,
"explicit default backend must not be overwritten by the sibling"
);
assert_eq!(
backend("kigi-plain"),
ApiBackend::Messages,
"unset backend still inherits"
);
assert_eq!(backend("kigi-messages"), ApiBackend::Messages);
}
/// Slug propagation should carry over api_backend but NOT agent_type.
#[test]
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
@@ -367,7 +367,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
let catalog = load_enrichment_catalog_at(&path);
assert!(
kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some(),
kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some(),
"bundled snapshot must back a total refresh failure"
);
assert!(!path.exists(), "failed refresh must not write a cache");
@@ -385,7 +385,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, token);
assert!(refresh_url().is_none(), "token {token:?} must disable");
let catalog = load_enrichment_catalog_at(&path);
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some());
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some());
}
}
@@ -12,8 +12,7 @@ use std::sync::Arc;
use serde::Serialize;
/// Where non-subscription users (no OAuth session) submit feedback instead.
pub const FEEDBACK_ISSUES_URL: &str =
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// HTTP error from the feedback endpoint with a preserved status code, so
/// callers can distinguish auth failures (401) without string matching.
@@ -210,9 +210,9 @@ pub(crate) async fn apply(
auto_compact_threshold_percent: new_threshold,
responds_to: tx,
});
let updated_model = rx
let (updated_model, context_window) = rx
.await
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))??;
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
handle.model_id = model_id.clone();
handle.reasoning_effort = applied_effort;
@@ -224,6 +224,7 @@ pub(crate) async fn apply(
&session_id,
model_id.0.as_ref(),
applied_effort.map(|eff| eff.to_string()),
context_window.get(),
);
if agent.cfg.borrow().mode != config::AgentMode::Leader {
agent.models_manager.set_current_model_id(model_id);
@@ -232,9 +233,12 @@ pub(crate) async fn apply(
.set_current_reasoning_effort(applied_effort);
}
Ok(acp::SetSessionModelResponse::new().meta(
serde_json::json!({ "model" : updated_model, })
.as_object()
.cloned(),
serde_json::json!({
"model": updated_model,
"totalContextTokens": context_window.get(),
})
.as_object()
.cloned(),
))
}
/// Broadcast a `ModelChanged` to every client subscribed to this session so
@@ -245,12 +249,14 @@ fn broadcast_model_changed(
session_id: &acp::SessionId,
model_id: &str,
reasoning_effort: Option<String>,
context_window: u64,
) {
let notification = crate::extensions::notification::SessionNotification {
session_id: session_id.clone(),
update: crate::extensions::notification::SessionUpdate::ModelChanged {
model_id: model_id.to_owned(),
reasoning_effort,
context_window: Some(context_window),
},
meta: None,
};
@@ -1473,7 +1473,7 @@ mod tests {
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
/// the fetch returns exactly the 4 compiled-in models keyed
/// the fetch returns exactly the 7 compiled-in models keyed
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
@@ -1510,20 +1510,18 @@ mod tests {
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec![
"openai-codex/gpt-6-astra",
"openai-codex/gpt-6-sol",
"openai-codex/gpt-6-luna",
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
"openai-codex/gpt-5.5",
],
"exactly the 4 hardcoded models, keyed openai-codex/<slug>"
"exactly the 7 hardcoded models, keyed openai-codex/<slug>"
);
// Excluded models never appear.
for absent in [
"openai-codex/gpt-5.3-codex-spark",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.4-mini",
"openai-codex/codex-auto-review",
] {
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
assert!(
!result
.models
@@ -1532,7 +1530,14 @@ mod tests {
"{absent} must be absent from the hardcoded catalog"
);
}
let sol = &result.models[0];
let astra = &result.models[0];
assert_eq!(astra.name.as_deref(), Some("GPT-6-Astra"));
assert_eq!(astra.context_window.get(), 272_000);
let sol = result
.models
.iter()
.find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.6-sol"))
.unwrap();
assert_eq!(
sol.api_backend,
crate::sampling::ApiBackend::Responses,
@@ -1365,7 +1365,8 @@ impl MvpAgent {
/// `FlushAndAck` on the persistence actor — a true sync barrier that only
/// resolves after all queued writes (chat messages, updates) hit disk.
///
/// Returns `Ok(())` on success, `Err(reason)` on timeout or channel failure.
/// Returns `Ok(())` once the writes are on disk, `Err(reason)` on a failed
/// flush, timeout or channel failure.
pub(crate) async fn flush_session(
&self,
session_id: &acp::SessionId,
@@ -1384,7 +1385,11 @@ impl MvpAgent {
return Err("send failed");
}
match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
Ok(Ok(_)) => Ok(()),
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(error))) => {
tracing::error!(session_id = %session_id.0, %error, "persistence flush failed");
Err("persistence flush failed")
}
Ok(Err(_)) => Err("channel closed"),
Err(_) => Err("timeout"),
}
@@ -27,7 +27,7 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
kigi_workspace::permission::spawn_permission_manager(
sid.clone(),
gateway,
cwd,
cwd.clone(),
kigi_workspace::permission::types::ClientType::Generic,
Some(PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
@@ -64,6 +64,8 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
Some("child-session".to_owned()),
Some("general-purpose".to_owned()),
Some("permission inheritance regression".to_owned()),
cwd.clone(),
None,
)
.await;
assert!(
@@ -19,6 +19,99 @@ use kigi_tools::implementations::kigi::task::types::*;
use kigi_workspace::file_system::AsyncFileSystem;
use kigi_hunk_tracker::HunkTrackerHandle;
use super::*;
/// Bounds each parent-side await in the child completion path so a starved parent `cmd_rx` cannot park a completed child forever.
pub(super) const PARENT_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Bounds reads of the child's own actors, which freeze with a tool that blocks the child thread; teardown must still reach `Shutdown`.
pub(super) const CHILD_ACTOR_ACK_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(10);
/// One bounded child-actor query, degrading to `fallback` when the actor never answers.
pub(super) async fn child_actor_query<T>(
what: &'static str,
query: impl std::future::Future<Output = T>,
fallback: T,
) -> T {
match tokio::time::timeout(CHILD_ACTOR_ACK_TIMEOUT, query).await {
Ok(value) => value,
Err(_) => {
tracing::warn!(query = what, "child actor did not answer in time; using fallback");
fallback
}
}
}
/// Bounds the post-Shutdown worktree snapshot (a `git add` behind a stuck clean filter must not park completion).
const WORKTREE_SNAPSHOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// Snapshot the child worktree to `ref_name`, persist the ref, then remove the worktree; returns `(snapshot_ref, removed)`.
async fn dispose_subagent_worktree(
worktree: &Path,
source_repo: &Path,
ref_name: &str,
meta_dir: &Path,
final_status: &str,
subagent_id: &str,
) -> (Option<String>, bool) {
let snapshot = tokio::time::timeout(
WORKTREE_SNAPSHOT_TIMEOUT,
crate::session::worktree::snapshot_subagent_worktree(worktree, source_repo, ref_name),
)
.await;
let snapshot_ref = match snapshot {
Ok(Ok(snapshot_ref)) => snapshot_ref,
Ok(Err(e)) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
"Failed to snapshot subagent worktree; preserving for review"
);
return (None, false);
}
Err(_) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"worktree snapshot did not finish in time; preserving worktree for resume"
);
return (None, false);
}
};
if !update_subagent_meta_snapshot_ref(meta_dir, &snapshot_ref, final_status) {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"snapshot_ref not persisted; preserving worktree for resume"
);
return (None, false);
}
// Removal is awaited in full: a detached deletion could race a resume that rehydrates into this path.
match crate::session::worktree::remove_subagent_worktree(worktree).await {
Ok(()) => {
tracing::info!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"snapshotted and removed subagent worktree"
);
(Some(snapshot_ref), true)
}
Err(e) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
"snapshotted subagent worktree but removal failed; ref persisted for resume"
);
(Some(snapshot_ref), false)
}
}
}
/// Await a parent ack for at most `PARENT_ACK_TIMEOUT`; `false` when it never answers or drops the sender.
pub(super) async fn parent_ack(what: &'static str, ack: oneshot::Receiver<()>) -> bool {
match tokio::time::timeout(PARENT_ACK_TIMEOUT, ack).await {
Ok(Ok(())) => true,
Ok(Err(_)) => false,
Err(_) => {
tracing::warn!(ack = what, "parent did not ack in time; proceeding to teardown");
false
}
}
}
pub(super) fn task_model_override_error(
requested: Option<&str>,
provenance: ModelOverrideProvenance,
@@ -895,13 +988,22 @@ pub(crate) async fn handle_subagent_request(
}
}
}
let agent_mcp_servers: Vec<_> = if is_plugin_agent {
if !definition.mcp_servers.is_empty() {
tracing::warn!(
agent = % definition.name, plugin = ? definition.plugin_name,
"ignoring mcpServers on plugin agent (not supported for security)"
);
}
let agent_mcp_servers: Vec<_> = if definition.mcp_servers.is_empty() {
vec![]
} else if is_plugin_agent {
tracing::warn!(
agent = % definition.name, plugin = ? definition.plugin_name,
"ignoring mcpServers on plugin agent (not supported for security)"
);
vec![]
} else if !crate::agent::folder_trust::agent_inline_hooks_allowed(definition.scope, || {
crate::agent::folder_trust::project_scope_allowed(&ctx.parent_cwd)
}) {
// An untrusted repo's inline stdio MCP command would run like an inline hook.
tracing::warn!(
agent = % definition.name,
"ignoring mcpServers on untrusted project agent (folder not trusted; re-run with --trust)"
);
vec![]
} else {
definition
@@ -1333,7 +1435,12 @@ pub(crate) async fn handle_subagent_request(
let duration_ms = start.elapsed().as_millis() as u64;
let mut result = match wait_outcome {
SubagentWaitOutcome::Cancelled => {
let (tool_calls, turns) = signals_snapshot_counts(&child_handle).await;
let (tool_calls, turns) = child_actor_query(
"signals_snapshot_counts",
signals_snapshot_counts(&child_handle),
(0, 0),
)
.await;
SubagentResult {
success: false,
cancelled: true,
@@ -1360,14 +1467,28 @@ pub(crate) async fn handle_subagent_request(
},
),
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
_ => signals_snapshot_counts(&child_handle).await,
_ => {
child_actor_query(
"signals_snapshot_counts",
signals_snapshot_counts(&child_handle),
(0, 0),
)
.await
}
};
let final_text = child_handle
.chat_state_handle
.get_last_assistant_text()
.await
.unwrap_or_default();
let result_tokens = child_handle.chat_state_handle.get_total_tokens().await;
let final_text = child_actor_query(
"get_last_assistant_text",
child_handle.chat_state_handle.get_last_assistant_text(),
None,
)
.await
.unwrap_or_default();
let result_tokens = child_actor_query(
"get_total_tokens",
child_handle.chat_state_handle.get_total_tokens(),
0,
)
.await;
match *turn_result {
Ok(
Ok(
@@ -1527,14 +1648,21 @@ pub(crate) async fn handle_subagent_request(
let final_status = result.status().to_string();
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
let telemetry_tokens = if result.tool_calls > 0 || result.success {
child_handle.chat_state_handle.get_total_tokens().await
child_actor_query(
"get_total_tokens",
child_handle.chat_state_handle.get_total_tokens(),
0,
)
.await
} else {
0
};
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
.chat_state_handle
.try_get_session_usage()
.await
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_actor_query(
"try_get_session_usage",
child_handle.chat_state_handle.try_get_session_usage(),
Err(()),
)
.await
{
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
Err(()) => (None, true),
@@ -1553,7 +1681,7 @@ pub(crate) async fn handle_subagent_request(
respond_to,
})
{
Ok(()) => ack.await.is_ok(),
Ok(()) => parent_ack("RecordSubagentUsage", ack).await,
Err(_) => false,
}
} else {
@@ -1570,53 +1698,71 @@ pub(crate) async fn handle_subagent_request(
.parent_prompt_id
.clone()
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
// A slow parent may apply both the fold and this mark; both are idempotent.
let marked_by_parent = if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
let (respond_to, ack) = tokio::sync::oneshot::channel();
if cmd_tx
cmd_tx
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
parent_prompt_id: sticky_prompt,
parent_prompt_id: sticky_prompt.clone(),
respond_to,
})
.is_ok()
{
let _ = ack.await;
}
} else if let Some(ref pid) = sticky_prompt {
&& parent_ack("MarkSubagentUsageNotApplied", ack).await
} else {
false
};
if !marked_by_parent && let Some(ref pid) = sticky_prompt {
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
}
}
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
(Some(parent_tb), Some(parent_notif_handle)) => {
if !request.surface_completion {
let reparented_task_ids: Vec<String> = parent_tb
.list_tasks()
.await
.into_iter()
.filter(|t| {
!t.completed
&& t.owner_session_id.as_deref()
== Some(&*child_session_id.0)
})
.map(|t| t.task_id)
.collect();
if !reparented_task_ids.is_empty()
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
{
let _ = cmd_tx
.send(SessionCommand::RecordGoalTurnTaskIds {
task_ids: reparented_task_ids,
});
match tokio::time::timeout(PARENT_ACK_TIMEOUT, parent_tb.list_tasks()).await {
Ok(tasks) => {
let reparented_task_ids: Vec<String> = tasks
.into_iter()
.filter(|t| {
!t.completed
&& t.owner_session_id.as_deref()
== Some(&*child_session_id.0)
})
.map(|t| t.task_id)
.collect();
if !reparented_task_ids.is_empty()
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
{
let _ = cmd_tx.send(SessionCommand::RecordGoalTurnTaskIds {
task_ids: reparented_task_ids,
});
}
}
Err(_) => tracing::warn!(
child_session_id = % child_session_id.0,
parent_session_id = % ctx.parent_session_id,
"list_tasks not answered in time; skipping goal-turn task-id recording"
),
}
}
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
parent_tb
.reparent_notifications(
if tokio::time::timeout(
PARENT_ACK_TIMEOUT,
parent_tb.reparent_notifications(
&child_session_id.0,
&ctx.parent_session_id,
parent_notif_handle.clone(),
parent_backend_weak,
)
.await;
),
)
.await
.is_err()
{
tracing::warn!(
child_session_id = % child_session_id.0,
parent_session_id = % ctx.parent_session_id,
"reparent_notifications not acked in time; proceeding to child shutdown"
);
}
}
(Some(_), None) | (None, Some(_)) => {
tracing::warn!(
@@ -1637,55 +1783,17 @@ pub(crate) async fn handle_subagent_request(
if snapshot_dispose_enabled {
let ref_name = format!("refs/kigi/subagents/{}", request.id);
let source_repo = resolve_subagent_source_repo(&ctx);
match crate::session::worktree::snapshot_subagent_worktree(
wt_path,
&source_repo,
&ref_name,
)
.await
{
Ok(snapshot_ref) => {
let persisted = update_subagent_meta_snapshot_ref(
&subagent_meta_dir,
&snapshot_ref,
&final_status,
);
if persisted {
disposed_snapshot_ref = Some(snapshot_ref);
match crate::session::worktree::remove_subagent_worktree(wt_path)
.await
{
Ok(()) => {
worktree_removed = true;
tracing::info!(
subagent_id = % request.id, worktree_path = % wt_path
.display(), "snapshotted and removed subagent worktree"
);
}
Err(e) => {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path
.display(), error = % e,
"snapshotted subagent worktree but removal failed; ref persisted for resume"
)
}
}
} else {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path
.display(),
"snapshot_ref not persisted; preserving worktree for resume"
);
}
}
Err(e) => {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path.display(),
error = % e,
"Failed to snapshot subagent worktree; preserving for review"
);
}
}
let (snapshot_ref, removed) = dispose_subagent_worktree(
wt_path,
&source_repo,
&ref_name,
&subagent_meta_dir,
&final_status,
&request.id,
)
.await;
disposed_snapshot_ref = snapshot_ref;
worktree_removed = removed;
} else {
tracing::info!(
subagent_id = % request.id, worktree_path = % wt_path.display(),
@@ -3298,3 +3298,27 @@ fn spawn_test_parent_chat_state(model_slug: &str) -> kigi_chat_state::ChatStateH
)
}
mod rest;
/// A blocked child actor must not park teardown: the read degrades to the fallback in bounded time.
#[tokio::test(start_paused = true)]
async fn child_actor_query_is_bounded_when_the_actor_never_answers() {
use super::handle_request::{CHILD_ACTOR_ACK_TIMEOUT, child_actor_query};
let tokens = tokio::time::timeout(
20 * CHILD_ACTOR_ACK_TIMEOUT,
child_actor_query("test_query", std::future::pending::<u64>(), 7),
)
.await
.expect("child-actor queries must complete in bounded time");
assert_eq!(tokens, 7, "a starved query must degrade to the fallback");
}
/// A parent that never services `cmd_rx` must not park a completed child.
#[tokio::test(start_paused = true)]
async fn parent_ack_is_bounded_when_the_parent_never_answers() {
use super::handle_request::{PARENT_ACK_TIMEOUT, parent_ack};
let (_never_answered, ack) = tokio::sync::oneshot::channel::<()>();
let acked = tokio::time::timeout(20 * PARENT_ACK_TIMEOUT, parent_ack("test_ack", ack))
.await
.expect("parent acks must complete in bounded time");
assert!(!acked, "an unanswered ack must report a miss");
}
@@ -638,6 +638,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let config_path = &crate::util::config::config_write_dest(config_path)?;
let tmp = config_path.with_extension("toml.tmp");
// Best-effort cleanup of the .tmp file if either write or rename fails so
// a failed marker write doesn't leave a stale artefact next to the real
@@ -820,6 +821,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
if count > 0 {
let toml_str = toml::to_string_pretty(&root)?;
let config_path = &crate::util::config::config_write_dest(config_path)?;
let tmp = config_path.with_extension("toml.tmp");
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
+18 -7
View File
@@ -1034,16 +1034,27 @@ pub fn apply_sandbox(
.and_then(|p| dunce::canonicalize(p).ok())
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| std::path::PathBuf::from("."));
if kigi_sandbox::restricts_home_writes(&sandbox_profile, &workspace) {
// The profile leaves ~/.kigi read-only outside its state dirs: mint
// what first use would otherwise write after enforcement.
if let Err(e) = crate::auth::device::device_id() {
tracing::warn!(error = %e, "sandbox: device id not created before enforcement");
}
kigi_tools::implementations::kigi::grep::ripgrep::rg_path();
crate::builtin::extract_bundled_files(&crate::util::kigi_home::kigi_home());
}
// The devbox `/data` write-deny is a bwrap mount; Landlock would grant the workspace under `/data`.
#[cfg(target_os = "linux")]
let requires_read_deny = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace);
let bwrap_required = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace)
|| kigi_sandbox::requires_data_write_deny(&sandbox_profile, &workspace);
#[cfg(target_os = "linux")]
{
let refuse_unprotected = |detail: &str| {
eprintln!(
"error: this sandbox could not enforce its read-deny set on Linux \
(bubblewrap missing/unusable, or a deny glob exceeded its expansion \
limit — see any message above). Install bubblewrap with \
`apt install -y bubblewrap` if needed. Refusing to start with denied \
"error: this sandbox could not enforce its read-deny set or the devbox \
`/data` write-deny on Linux (bubblewrap missing/unusable, or a deny glob \
exceeded its expansion limit — see any message above). Install bubblewrap \
with `apt install -y bubblewrap` if needed. Refusing to start with denied \
paths unprotected.{detail}"
);
};
@@ -1051,7 +1062,7 @@ pub fn apply_sandbox(
Some(mut cmd) => {
use std::os::unix::process::CommandExt;
let err = cmd.exec();
if requires_read_deny {
if bwrap_required {
refuse_unprotected(&format!(" (bwrap exec failed: {err})"));
std::process::exit(1);
}
@@ -1061,7 +1072,7 @@ pub fn apply_sandbox(
Install bubblewrap: apt install -y bubblewrap"
);
}
None if requires_read_deny && !kigi_sandbox::is_inside_bwrap() => {
None if bwrap_required && !kigi_sandbox::is_inside_bwrap() => {
refuse_unprotected("");
std::process::exit(1);
}
@@ -37,9 +37,9 @@ async fn handle_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
respond_to: tx,
});
}
// The session's own ACP error goes through unchanged: a Debug wrapper would escape its detail.
rx.await
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
.map_err(|e| acp::Error::internal_error().data(format!("Internal error: {:?}", e)))?;
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))??;
to_raw_response(&CompactConversationResponse {})
}
@@ -691,6 +691,11 @@ pub enum SessionUpdate {
/// does not support reasoning effort or no effort override was applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
/// The session's effective context window after the switch, so a
/// follower repaints its header without waiting for the next turn.
/// `None` from an agent that predates the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
context_window: Option<u64>,
},
/// Streaming chunk of a tool call's arguments.
///
@@ -1970,15 +1975,18 @@ mod tests {
let with_effort = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("high".into()),
context_window: Some(262_144),
};
let json = serde_json::to_value(&with_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
assert_eq!(json["model_id"], "kigi-4");
assert_eq!(json["reasoning_effort"], "high");
assert_eq!(json["context_window"], 262_144);
let without_effort = SessionUpdate::ModelChanged {
model_id: "kigi-3".into(),
reasoning_effort: None,
context_window: None,
};
let json = serde_json::to_value(&without_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
@@ -1988,6 +1996,7 @@ mod tests {
"reasoning_effort: None must be skipped on the wire so old pagers \
and third-party ACP clients see a smaller, no-extra-keys payload"
);
assert!(json.get("context_window").is_none());
}
/// `ModelChanged` round-trips through JSON: a follower client deserializes
@@ -2000,6 +2009,7 @@ mod tests {
let original = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("medium".into()),
context_window: Some(1_000_000),
};
let json_str = serde_json::to_string(&original).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
@@ -2021,6 +2031,7 @@ mod tests {
update: SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: None,
context_window: None,
},
meta: None,
};
@@ -513,6 +513,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
if applied.total_added() > 0 {
// Atomic write: tmp + rename (same pattern as save_mcp_server_config).
let toml_str = toml::to_string_pretty(&root)?;
let config_path = crate::util::config::config_write_dest(&config_path)?;
let tmp = config_path.with_extension("toml.tmp");
if let Err(e) = std::fs::write(&tmp, &toml_str) {
let _ = std::fs::remove_file(&tmp);
+13 -4
View File
@@ -52,10 +52,16 @@ pub fn default_lock_path_in(root: &Path) -> PathBuf {
root.join("leader.lock")
}
/// Runtime files live under `~/.kigi/run`: the one home subtree a strict
/// sandbox leaves writable for them.
pub(super) fn runtime_root() -> PathBuf {
kigi_home().join("run")
}
/// Effective leader lock path: the [`LEADER_SOCKET_ENV`] override's sibling
/// `.lock` when set, else the default under kigi home.
/// `.lock` when set, else the default under `~/.kigi/run`.
pub fn default_lock_path() -> PathBuf {
resolve_lock_path(leader_socket_override(), &kigi_home())
resolve_lock_path(leader_socket_override(), &runtime_root())
}
pub fn default_socket_path_in(root: &Path) -> PathBuf {
@@ -63,9 +69,9 @@ pub fn default_socket_path_in(root: &Path) -> PathBuf {
}
/// Effective leader socket path: the [`LEADER_SOCKET_ENV`] override when set,
/// else the default under kigi home.
/// else the default under `~/.kigi/run`.
pub fn default_socket_path() -> PathBuf {
resolve_socket_path(leader_socket_override(), &kigi_home())
resolve_socket_path(leader_socket_override(), &runtime_root())
}
/// The instance suffix encoded in a lock/socket file-name pair
@@ -145,6 +151,9 @@ impl LeaderLock {
}
fn open_lock_file(&self) -> Result<File, LockError> {
if let Some(parent) = self.lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(OpenOptions::new()
.read(true)
.write(true)
+68 -5
View File
@@ -23,7 +23,7 @@
//! │ │ - Tracks session ownership for routing ││
//! │ └────────────────────────┬────────────────────────────────┘│
//! └───────────────────────────┼──────────────────────────────────┘
//! │ IPC (Unix socket at ~/.kigi/leader.sock)
//! │ IPC (Unix socket at ~/.kigi/run/leader.sock)
//! ┌───────────────────┼───────────────────┐
//! ▼ ▼ ▼
//! ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
@@ -481,7 +481,10 @@ async fn discover_leaders_in(root: &Path) -> Vec<LeaderDescriptor> {
entries
}
pub async fn discover_leaders() -> Vec<LeaderDescriptor> {
discover_leaders_in(&crate::util::kigi_home::kigi_home()).await
let mut entries = discover_leaders_in(&lock::runtime_root()).await;
// Pre-0.1.15 leaders and socket overrides keep their files at the home root.
entries.extend(discover_leaders_in(&crate::util::kigi_home::kigi_home()).await);
entries
}
/// (pid, leader_binary_version) of socket-verified (Reachable) leaders; a
/// stale-lock-only descriptor is skipped (its `pid_from_lock` may be recycled).
@@ -610,13 +613,28 @@ fn resolve_matching_descriptors(
fn resolve_target_from_descriptors(
target: LeaderTarget,
leaders: Vec<LeaderDescriptor>,
current_default_socket: &Path,
) -> Result<LeaderTargetSelection, LeaderTargetError> {
match target {
LeaderTarget::Default => {
let matching: Vec<_> = leaders
let defaults: Vec<_> = leaders
.into_iter()
.filter(|descriptor| descriptor.is_default)
.collect();
// An upgrade can leave a pre-0.1.15 default at the home root; the
// current default socket wins while it exists.
let current: Vec<_> = defaults
.iter()
.filter(|descriptor| {
descriptor.socket_path.as_deref() == Some(current_default_socket)
})
.cloned()
.collect();
let matching = if current.is_empty() {
defaults
} else {
current
};
resolve_matching_descriptors("default", matching)
}
LeaderTarget::Socket(socket_path) => {
@@ -685,7 +703,7 @@ pub async fn resolve_leader_target(
target: LeaderTarget,
) -> Result<LeaderTargetSelection, LeaderTargetError> {
let leaders = discover_leaders().await;
resolve_target_from_descriptors(target, leaders)
resolve_target_from_descriptors(target, leaders, &lock::default_socket_path())
}
#[derive(Debug, thiserror::Error)]
pub enum ConnectionError {
@@ -1315,7 +1333,12 @@ fn spawn_leader_subprocess() -> Result<u32, ConnectionError> {
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
let log_path = crate::util::kigi_home::kigi_home().join("leader.log");
let log_path = crate::util::kigi_home::kigi_home()
.join("logs")
.join("leader.log");
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::File::create(&log_path) {
Ok(log_file) => {
info!("Leader stderr → log file");
@@ -1444,6 +1467,46 @@ mod tests {
vec![(222, "0.2.52".to_string())]
);
}
fn reachable_default(socket: &str) -> LeaderDescriptor {
let socket_path = PathBuf::from(socket);
LeaderDescriptor {
pid_from_lock: Some(1),
lock_path: Some(socket_path.with_extension("lock")),
socket_path: Some(socket_path.clone()),
socket_suffix: String::new(),
classification: LeaderDiscoveryState::Reachable,
is_default: true,
live_info: Some(LiveLeaderInfo {
pid: 1,
socket_path,
lock_path: PathBuf::new(),
socket_suffix: String::new(),
leader_protocol_version: 1,
leader_binary_version: "0.1.15".to_string(),
}),
target_error: None,
}
}
#[test]
fn default_target_prefers_the_current_socket_over_a_legacy_default() {
let legacy = reachable_default("/h/.kigi/leader.sock");
let current = reachable_default("/h/.kigi/run/leader.sock");
let current_socket = PathBuf::from("/h/.kigi/run/leader.sock");
let picked = resolve_target_from_descriptors(
LeaderTarget::Default,
vec![current.clone(), legacy.clone()],
&current_socket,
)
.expect("current default resolves");
assert_eq!(picked.descriptor.socket_path, current.socket_path);
let fallback = resolve_target_from_descriptors(
LeaderTarget::Default,
vec![legacy.clone()],
&current_socket,
)
.expect("legacy default still resolves alone");
assert_eq!(fallback.descriptor.socket_path, legacy.socket_path);
}
#[test]
fn leader_is_older_than_directional() {
assert!(leader_is_older_than("0.1.0", "0.2.0"));
@@ -1146,6 +1146,9 @@ pub async fn run_leader_server(
control_state: LeaderServerControlState,
) -> Result<(), ServerError> {
let _ = std::fs::remove_file(&socket_path);
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
let shutdown_reason_rx = shutdown_tx.subscribe();
let listener = LeaderListener::bind(&socket_path)?;
info!("Leader server listening");
@@ -171,6 +171,70 @@ pub fn error_detail_from_data(data: &serde_json::Value) -> Option<String> {
.map(str::to_owned)
}
/// The human-readable text of an ACP error: its `data` detail when present (Display would JSON-escape it), else the message.
pub fn acp_error_message(err: &acp::Error) -> String {
err.data
.as_ref()
.and_then(error_detail_from_data)
.unwrap_or_else(|| err.message.clone())
}
/// First line of a compaction error without internal `compact failed:` prefixes, capped for a banner; diagnostic lines (request URLs) are dropped.
pub fn user_facing_compact_error(raw: &str) -> String {
const INTERNAL_PREFIX: &str = "compact failed:";
const MAX_CHARS: usize = 200;
// Redact before line selection: a line break inside a URL must not hide its `@`.
let raw = redact_urls_in_text(raw);
let mut rest = raw.trim();
while rest
.get(..INTERNAL_PREFIX.len())
.is_some_and(|head| head.eq_ignore_ascii_case(INTERNAL_PREFIX))
{
rest = rest[INTERNAL_PREFIX.len()..].trim_start();
}
rest.lines()
.next()
.unwrap_or_default()
.trim()
.chars()
.take(MAX_CHARS)
.collect()
}
/// Every canonical `scheme://` URL in `text` loses userinfo, sensitive query values and fragment; one that fails to parse is not echoed.
fn redact_urls_in_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(sep) = rest.find("://") {
// The delimiter before the scheme may be multi-byte (`“`, `:`): step past its UTF-8 length.
let scheme_start = rest[..sep]
.char_indices()
.rev()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
.map_or(0, |(at, c)| at + c.len_utf8());
out.push_str(&rest[..scheme_start]);
// The authority may hold whitespace (a configured password); the token ends at whitespace after it.
let authority = &rest[sep + 3..];
let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
let token_end = authority[authority_end..]
.find(char::is_whitespace)
.map_or(rest.len(), |at| sep + 3 + authority_end + at);
let token = &rest[scheme_start..token_end];
let url_text = token.trim_end_matches([')', ']', '}', ',', '.', ';', '\'', '"', '>']);
match url::Url::parse(url_text) {
Ok(mut url) => {
kigi_secrets::redact_url(&mut url);
out.push_str(url.as_str());
}
Err(_) => out.push_str("<unparseable url>"),
}
out.push_str(&token[url_text.len()..]);
rest = &rest[token_end..];
}
out.push_str(rest);
out
}
pub fn http_status_from_error(err: &acp::Error) -> Option<u16> {
err.data
.as_ref()?
@@ -254,6 +318,77 @@ pub fn prompt_complete_fields(
#[cfg(test)]
mod tests {
#[test]
fn compact_error_text_keeps_the_first_decoded_line_only() {
let err = super::acp::Error::internal_error().data(
"compact failed: API error (status 500): overloaded\n\nRequest URL: http://u:TEST_PASSWORD@127.0.0.1/v1",
);
let detail = super::user_facing_compact_error(&super::acp_error_message(&err));
assert_eq!(detail, "API error (status 500): overloaded");
assert!(!detail.contains("TEST_PASSWORD"));
assert_eq!(
super::user_facing_compact_error(&"x".repeat(500))
.chars()
.count(),
200
);
assert_eq!(
super::acp_error_message(&super::acp::Error::internal_error()),
"Internal error"
);
}
/// A sampler 401 names the endpoint on the retained line; its userinfo must not survive.
#[test]
fn compact_error_text_redacts_url_credentials_on_the_first_line() {
for password in [
"TEST_PASSWORD",
"TEST PASSWORD",
"TEST%20PASSWORD",
"TEST_PASSWORD\n",
"TEST_PASSWORD\r\n",
] {
let raw = format!(
"compact failed: Unauthorized (401) from http://u:{password}@127.0.0.1:33263/401/v1/chat/completions: bad auth"
);
assert_eq!(
super::user_facing_compact_error(&raw),
"Unauthorized (401) from http://127.0.0.1:33263/401/v1/chat/completions: bad auth",
"{password:?}"
);
}
assert_eq!(
super::redact_urls_in_text("see https://a:b@h.example/x and http://h2/"),
"see https://h.example/x and http://h2/"
);
}
/// A credential carried as a query value survives no failure shape: 401 message or transport error.
#[test]
fn compact_error_text_redacts_query_credentials_in_every_failure_shape() {
let unauthorized = "compact failed: Unauthorized (401) from http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/v1/chat/completions: bad auth";
let transport = "compact failed: request error: error sending request for url (http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/500/v1/chat/completions)";
for raw in [unauthorized, transport] {
let detail = super::user_facing_compact_error(raw);
assert!(!detail.contains("TEST_QUERY_TOKEN"), "{detail}");
assert!(detail.contains("route="), "{detail}");
}
assert!(super::user_facing_compact_error("see http://:://").contains("<unparseable url>"));
}
/// A multi-byte delimiter right before the URL must not break the scan.
#[test]
fn compact_error_text_handles_unicode_before_a_url() {
for raw in [
"compact failed: Service unavailable. See “https://u:TEST_PASSWORD@status.example/”.",
"compact failed: 请参阅:https://u:TEST_PASSWORD@status.example/",
] {
let detail = super::user_facing_compact_error(raw);
assert!(!detail.contains("TEST_PASSWORD"), "{detail}");
assert!(detail.contains("https://status.example/"), "{detail}");
}
}
use super::*;
use reqwest::StatusCode;
@@ -1518,6 +1518,9 @@ mod load_user_prompts_tests;
#[path = "acp_session_tests/memory_config_tests.rs"]
mod memory_config_tests;
#[cfg(test)]
#[path = "acp_session_tests/model_switch_window_tests.rs"]
mod model_switch_window_tests;
#[cfg(test)]
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
mod parallel_dispatch_tests;
#[cfg(test)]
@@ -10,7 +10,7 @@ impl SessionActor {
apply_prompt_override: bool,
skip_prompt_rewrite: bool,
auto_compact_threshold_percent: u8,
) -> Result<acp::ModelId, acp::Error> {
) -> Result<(acp::ModelId, std::num::NonZeroU64), acp::Error> {
let model_id = acp::ModelId::new(sampling_config.model.clone());
// H4: record the picker's catalog KEY as this SESSION's own selection.
// `sampling_config.model` is the ambiguous bare slug; the key is what
@@ -120,7 +120,7 @@ impl SessionActor {
agent_name: Some(agent_name),
reasoning_effort: Some(sampling_config.reasoning_effort),
});
Ok(model_id)
Ok((model_id, new_context_window))
}
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
///
@@ -548,17 +548,10 @@ impl SessionActor {
if report.changed() && !dry_run {
// Flush barrier: success must mean the rewrite is on disk.
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck {
respond_to: flush_tx,
})
.is_err()
|| flush_rx.await.is_err()
{
anyhow::bail!("history repaired in memory but the persistence flush failed");
if let Err(error) = self.persistence_barrier().await {
anyhow::bail!(
"history repaired in memory but the persistence flush failed: {error}"
);
}
tracing::warn!(
session_id = %self.session_info.id.0,
@@ -957,6 +957,8 @@ impl SessionActor {
Some(self.session_info.id.0.to_string()),
None,
None,
self.tool_context.cwd.clone(),
self.display_cwd.get().map(std::path::PathBuf::from),
)
.await
};
@@ -681,22 +681,14 @@ impl SessionActor {
.await
.is_some()
{
let (flush_tx, flush_rx) = oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck {
respond_to: flush_tx,
})
.is_ok()
&& flush_rx.await.is_ok()
{
let _ = ack.send(());
} else {
tracing::error!(
session_id = % self.session_info.id.0, prompt_id = %
prompt_id, "persist_ack flush barrier failed"
);
match self.persistence_barrier().await {
Ok(()) => {
let _ = ack.send(());
}
Err(error) => tracing::error!(
session_id = % self.session_info.id.0, prompt_id = % prompt_id,
%error, "persist_ack flush barrier failed"
),
}
} else {
tracing::error!(
@@ -333,15 +333,24 @@ impl SessionActor {
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
tracing::warn!(?e, "flush_replay_actor failed");
}
if let Err(error) = self.persistence_barrier().await {
tracing::error!(%error, "persistence flush failed");
}
}
/// The `FlushAndAck` durability barrier: `Ok` only once every prior
/// persistence write is on stable media.
pub(super) async fn persistence_barrier(&self) -> std::io::Result<()> {
let (tx, rx) = tokio::sync::oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
.is_ok()
.is_err()
{
let _ = rx.await;
return Err(std::io::Error::other("persistence actor gone"));
}
rx.await
.unwrap_or_else(|_| Err(std::io::Error::other("persistence actor gone")))
}
/// Extracts the update type name and relevant parameters for logging
fn extract_update_info(
@@ -435,7 +435,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
respond_to: flush_tx,
})
.unwrap();
flush_rx.await.unwrap();
flush_rx.await.unwrap().unwrap();
let loaded = storage
.load_session_without_updates(&session_info)
.await
@@ -757,7 +757,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
respond_to: flush_tx,
})
.unwrap();
flush_rx.await.unwrap();
flush_rx.await.unwrap().unwrap();
let storage = crate::session::storage::JsonlStorageAdapter::with_explicit_session_dir(
session_dir.path().to_path_buf(),
);
@@ -1310,7 +1310,7 @@ async fn actor_with_persistence_drain() -> std::sync::Arc<SessionActor> {
tokio::task::spawn_local(async move {
while let Some(msg) = persistence_rx.recv().await {
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
let _ = respond_to.send(());
let _ = respond_to.send(Ok(()));
}
}
});
@@ -0,0 +1,24 @@
//! `handle_set_session_model` reports the session's effective context
//! window so clients repaint their header without waiting for a turn.
use super::support::*;
#[tokio::test(flavor = "current_thread")]
async fn set_session_model_returns_the_effective_context_window() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (actor, _rx) = build_actor().await;
let cfg = kigi_sampler::SamplerConfig {
model: "narrow".to_string(),
context_window: 128_000,
..Default::default()
};
let (model_id, window) = actor
.handle_set_session_model(cfg, None, false, false, true, 85)
.await
.expect("model switch");
assert_eq!(model_id.0.as_ref(), "narrow");
assert_eq!(window.get(), 128_000);
})
.await;
}
@@ -82,6 +82,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
None,
None,
None,
session.tool_context.cwd.clone(),
None,
)
.await;
// cargo is heuristic-allow when sampling fails; must not be Prompt-only
@@ -99,6 +101,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
None,
None,
None,
session.tool_context.cwd.clone(),
None,
)
.await;
assert!(
@@ -173,7 +173,9 @@ pub enum SessionCommand {
/// `compaction.threshold_percent` (which is `Cell<u8>` so it can
/// update without `&mut self`).
auto_compact_threshold_percent: u8,
responds_to: oneshot::Sender<Result<acp::ModelId, acp::Error>>,
/// Ok: the applied model id and the session's effective context
/// window, which clients repaint from without waiting for a turn.
responds_to: oneshot::Sender<Result<(acp::ModelId, std::num::NonZeroU64), acp::Error>>,
},
/// Zero-turn harness rebuild: build a brand-new `Agent` from the
/// session's `AgentRebuildSpec` and the new `AgentDefinition`,
@@ -332,10 +334,10 @@ pub enum SessionCommand {
CopyFile {
respond_to: oneshot::Sender<anyhow::Result<crate::session::persistence::SessionStateCopy>>,
},
/// Flush the replay buffer and persistence, then signal completion.
/// Used during reconnect to ensure all buffered content is persisted before replay.
/// Flush the replay buffer and persistence, then report the durability
/// barrier result. Used during reconnect so replay follows persisted content.
FlushComplete {
respond_to: oneshot::Sender<()>,
respond_to: oneshot::Sender<std::io::Result<()>>,
},
/// Update MCP servers for an existing session (used during reconnect or
/// mid-session via the `kigi/session/update_mcp_servers` extension method).
@@ -689,6 +689,19 @@ impl SessionActor {
.await;
}
}
/// Retry guidance headline, then the user-facing detail on its own line.
fn failure_with_retry_guidance(err: &acp::Error) -> String {
const RETRY_GUIDANCE: &str =
"it'll retry on the next turn, or start a new session using /new.";
let detail = crate::sampling::error::user_facing_compact_error(
&crate::sampling::error::acp_error_message(err),
);
if detail.is_empty() {
RETRY_GUIDANCE.to_string()
} else {
format!("{RETRY_GUIDANCE}\n{detail}")
}
}
/// Map a deterministic failure's error text to a fixed, content-free
/// [`SuppressReason`] (drives telemetry + sticky-vs-per-turn scope).
fn classify_suppress_reason(error_msg: &str) -> SuppressReason {
@@ -1945,7 +1958,7 @@ impl SessionActor {
== SUPPRESS_NONE
{
self.send_xai_notification(XaiSessionUpdate::AutoCompactFailed {
error: String::new(),
error: Self::failure_with_retry_guidance(&e),
})
.await;
}
@@ -3286,3 +3299,28 @@ mod inline_auto_compact_flow_tests {
.await;
}
}
#[cfg(test)]
mod failure_message_tests {
use super::SessionActor;
use agent_client_protocol as acp;
#[test]
fn transient_failure_message_carries_guidance_and_the_decoded_detail() {
let err = acp::Error::internal_error().data(
"Compact failed: compact failed: HTTP 500 from upstream\n\nRequest URL: http://u:TEST_PASSWORD@host/v1",
);
let text = SessionActor::failure_with_retry_guidance(&err);
let (headline, detail) = text.split_once('\n').expect("guidance + detail");
assert_eq!(
headline,
"it'll retry on the next turn, or start a new session using /new."
);
assert_eq!(detail, "HTTP 500 from upstream");
assert!(!text.contains("TEST_PASSWORD"));
assert_eq!(
SessionActor::failure_with_retry_guidance(&acp::Error::internal_error().data(" ")),
"it'll retry on the next turn, or start a new session using /new."
);
}
}
@@ -519,6 +519,9 @@ pub(crate) async fn capture_plan_changes(
}
}
/// Pin the `a/` `b/` header prefixes [`extract_changed_files`] parses.
const DIFF_PREFIX_ARGS: [&str; 2] = ["--src-prefix=a/", "--dst-prefix=b/"];
/// Run `git diff <baseline>` and return the FULL (untruncated) stdout;
/// the caller extracts the changed-file list before truncating.
async fn run_git_diff_against_baseline(
@@ -526,7 +529,7 @@ async fn run_git_diff_against_baseline(
workspace_root: &Path,
) -> Result<String, ChangesCaptureError> {
let mut cmd = git_command(workspace_root);
cmd.arg("diff").arg(baseline);
cmd.arg("diff").args(DIFF_PREFIX_ARGS).arg(baseline);
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())),
@@ -579,10 +582,14 @@ async fn lazy_git_baseline_diff(
// support SHA-256 repos.
let mut cmd = git_command(workspace_root);
if git_has_parent(workspace_root, &oldest).await {
cmd.arg("diff").arg(format!("{oldest}^..{head}"));
cmd.arg("diff")
.args(DIFF_PREFIX_ARGS)
.arg(format!("{oldest}^..{head}"));
} else {
let empty_tree = derive_empty_tree_sha(workspace_root).await;
cmd.arg("diff").arg(format!("{empty_tree}..{head}"));
cmd.arg("diff")
.args(DIFF_PREFIX_ARGS)
.arg(format!("{empty_tree}..{head}"));
}
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
@@ -2192,4 +2199,38 @@ mod tests {
"patch must carry the untracked-files note",
);
}
#[tokio::test]
async fn changed_files_ignore_the_host_diff_prefix_config() {
// Both keys rewrite the `diff --git a/<old> b/<new>` header the parser keys on.
for key in ["diff.mnemonicprefix", "diff.noprefix"] {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
git(tmp.path(), &["config", key, "true"]);
tokio::fs::write(tmp.path().join("tracked.txt"), b"seed\n")
.await
.unwrap();
let goal_created_at: i64 = 1_700_000_100;
git_at(tmp.path(), &["add", "."], goal_created_at + 100);
git_at(
tmp.path(),
&["commit", "-q", "-m", "post-goal"],
goal_created_at + 100,
);
let lazy = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("lazy baseline must succeed");
assert_eq!(lazy.changed_files, ["tracked.txt"], "{key}: lazy baseline");
tokio::fs::write(tmp.path().join("tracked.txt"), b"changed\n")
.await
.unwrap();
let explicit = capture_changes_diff(Some("HEAD"), tmp.path(), goal_created_at)
.await
.expect("layer-1 git diff must succeed");
assert_eq!(
explicit.changed_files,
["tracked.txt"],
"{key}: explicit baseline"
);
}
}
}
@@ -467,7 +467,24 @@ fn compute_normalized_blocking(
});
}
};
if buf.len() >= original_bytes {
// A resize that falls under the vision floors cannot be sent; keep the original with the fallback note.
if new_w < MIN_VISION_SIDE_PX
|| new_h < MIN_VISION_SIDE_PX
|| u64::from(new_w) * u64::from(new_h) < MIN_VISION_TOTAL_PX
{
tracing::warn!(
index,
width = new_w,
height = new_h,
"image resize fell under the minimum dimensions; keeping original attachment"
);
return Ok(NormalizedEntry::ReEncodingOversized {
bytes: Bytes::from(raw_bytes),
mime: Cow::Borrowed(orig_mime),
});
}
// An image over the dimension caps is resized even when that saves no bytes.
if !exceeded_dimensions && buf.len() >= original_bytes {
return Ok(NormalizedEntry::Unchanged {
bytes: Bytes::from(raw_bytes),
mime: Cow::Borrowed(orig_mime),
@@ -551,6 +568,50 @@ mod tests {
image::ImageFormat::Png
);
}
/// A flat image at the best PNG compression is tinier than any re-encode the fast encoder produces.
fn make_best_compressed_png_content(width: u32, height: u32) -> ImageContent {
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{ImageBuffer, ImageEncoder, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
let mut buf = Vec::new();
PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive)
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgba8)
.unwrap();
ImageContent::new(
base64::engine::general_purpose::STANDARD.encode(&buf),
"image/png",
)
}
/// Over the dimension caps, a re-encode that saves no bytes is still a resize.
#[tokio::test]
async fn over_dimension_image_is_resized_even_when_re_encode_saves_no_bytes() {
let img = make_best_compressed_png_content(3000, 2000);
let cache = fresh_cache();
match normalize_one_in(img, 1, false, &cache).await {
Outcome::Compressed { info, .. } => {
assert!(
info.compressed_bytes >= info.original_bytes,
"test premise: the re-encode must not shrink ({} vs {})",
info.compressed_bytes,
info.original_bytes
);
assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX);
assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX);
}
other => panic!("expected the oversized image to be downscaled, got {other:?}"),
}
}
/// A 3000x8 strip resizes to 2000x5, under the 8 px floor: it is kept with the fallback note.
#[tokio::test]
async fn resize_under_the_minimum_side_keeps_the_original() {
let img = make_best_compressed_png_content(3000, 8);
let cache = fresh_cache();
match normalize_one_in(img, 1, false, &cache).await {
Outcome::ReEncodingOversized(content) => assert_eq!(content.mime_type, "image/png"),
other => panic!("expected the strip to be kept with a fallback note, got {other:?}"),
}
}
fn make_gif_content(width: u32, height: u32) -> ImageContent {
use image::{ImageBuffer, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
@@ -359,11 +359,13 @@ pub enum PersistenceMsg {
/// stays sequential with other summary.json mutations.
GeneratedTitle(String),
Flush,
/// Flush all pending writes, then signal the caller once the flush is complete.
/// Unlike `Flush` (fire-and-forget), this is a **sync barrier**: the caller's
/// oneshot only resolves after `flush_pending()` finishes writing to disk.
/// Flush all pending writes AND fsync the session files, then signal the
/// caller. Unlike `Flush` (fire-and-forget, page cache only), this is a
/// **durability barrier**: `Ok` means every prior write is on stable
/// media; `Err` reports a storage write that failed since the last
/// barrier or a failed sync.
FlushAndAck {
respond_to: tokio::sync::oneshot::Sender<()>,
respond_to: tokio::sync::oneshot::Sender<io::Result<()>>,
},
/// Flush all pending writes, then copy the current session directory contents and return
/// the in-memory snapshot to the caller (who can tar.gz + upload to GCS, etc.).
@@ -1392,6 +1394,9 @@ struct SessionPersistence {
storage: Arc<dyn StorageAdapter>,
/// Pending ACP notification for merging consecutive text chunks
pending_notification: Option<acp::SessionNotification>,
/// First storage failure since the last barrier: `FlushAndAck` must not
/// report `Ok` for state that never reached the file.
pending_write_error: Option<io::Error>,
rx: mpsc::UnboundedReceiver<PersistenceMsg>,
/// Session title generation lifecycle.
summary: crate::session::summary::SummaryGenerator,
@@ -1491,29 +1496,49 @@ impl SessionPersistence {
}
}
async fn write_update(&mut self, update: &SessionUpdate) {
if let Err(e) = self.storage.append_update(&self.info, update).await {
tracing::warn!(?e, "failed to write update");
fn note_storage_failure(&mut self, error: &io::Error) {
if self.pending_write_error.is_none() {
self.pending_write_error = Some(io::Error::new(error.kind(), error.to_string()));
}
}
/// Flush any pending merged ACP notification to disk.
async fn flush_pending(&mut self) {
// Write any pending merged ACP notification
if let Some(notification) = self.pending_notification.take() {
self.write_update(&SessionUpdate::Acp(Box::new(notification)))
.await;
/// Append an update; a failure is logged and latched for the next barrier.
async fn write_update(&mut self, update: &SessionUpdate) -> io::Result<()> {
let result = self.storage.append_update(&self.info, update).await;
if let Err(error) = &result {
tracing::warn!(%error, "failed to write update");
self.note_storage_failure(error);
}
result
}
/// Write the pending merged ACP notification.
async fn flush_pending(&mut self) -> io::Result<()> {
match self.pending_notification.take() {
Some(notification) => {
self.write_update(&SessionUpdate::Acp(Box::new(notification)))
.await
}
None => Ok(()),
}
}
/// Flush pending writes and sync all session files to disk.
/// Called before CopyFile to ensure all data is persisted.
async fn flush_and_sync(&mut self) {
self.flush_pending().await;
// Sync all session files to disk to ensure they're actually written
if let Err(e) = self.storage.sync_session_files(&self.info).await {
tracing::warn!(?e, "Failed to sync session files to disk");
/// Fsync every session file to stable media.
async fn sync_files(&self) -> io::Result<()> {
let synced = self.storage.sync_session_files(&self.info).await;
if let Err(error) = &synced {
tracing::warn!(%error, "failed to sync session files to disk");
}
synced
}
/// The `FlushAndAck` barrier. First error wins: a write that failed since
/// the last barrier outranks a failed sync.
async fn flush_and_sync(&mut self) -> io::Result<()> {
let flushed = self.flush_pending().await;
let prior_write = self.pending_write_error.take().map_or(Ok(()), Err);
let synced = self.sync_files().await;
flushed.and(prior_write).and(synced)
}
async fn run(mut self) {
@@ -1530,34 +1555,38 @@ impl SessionPersistence {
}
match msg {
PersistenceMsg::Flush => {
self.flush_pending().await;
// Logged and latched by write_update; the next barrier reports it.
let _ = self.flush_pending().await;
}
PersistenceMsg::FlushAndAck { respond_to } => {
self.flush_pending().await;
let _ = respond_to.send(());
let result = self.flush_and_sync().await;
let _ = respond_to.send(result);
}
PersistenceMsg::Update(update) => {
match update {
SessionUpdate::Acp(notification) => {
// ACP notifications use merging to coalesce consecutive text chunks
if let Some(to_write) = self.maybe_merge_notification(&notification) {
self.write_update(&SessionUpdate::Acp(Box::new(to_write)))
// Logged and latched by write_update.
let _ = self
.write_update(&SessionUpdate::Acp(Box::new(to_write)))
.await;
}
}
SessionUpdate::Xai(_) => {
// xAI notifications are written directly without merging
self.write_update(&update).await;
// Written directly without merging; logged and latched by write_update.
let _ = self.write_update(&update).await;
}
}
}
PersistenceMsg::Chat(chat_msg) => {
if let Err(e) = self
if let Err(error) = self
.storage
.append_chat_message(&self.info, &chat_msg)
.await
{
tracing::warn!(?e, "failed to write chat message");
tracing::warn!(%error, "failed to write chat message");
self.note_storage_failure(&error);
}
}
PersistenceMsg::ReplaceChatHistory(messages) => {
@@ -1571,6 +1600,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to replace chat history");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CurrentModel {
@@ -1589,21 +1619,25 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to update current model");
self.note_storage_failure(&e);
}
}
PersistenceMsg::PlanState(state) => {
if let Err(e) = self.storage.write_plan_state(&self.info, &state).await {
tracing::warn!(?e, "failed to write plan state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::PlanModeState(state) => {
if let Err(e) = self.storage.write_plan_mode_state(&self.info, &state).await {
tracing::warn!(?e, "failed to write plan mode state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::GoalModeState(state) => {
if let Err(e) = self.storage.write_goal_mode_state(&self.info, &state).await {
tracing::warn!(?e, "failed to write goal mode state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::GraphModeState(state) => {
@@ -1613,6 +1647,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write graph mode state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::ContentChunk(content_chunks) => {
@@ -1686,12 +1721,14 @@ impl SessionPersistence {
}
Err(e) => {
tracing::warn!(?e, "failed to persist generated session title");
self.note_storage_failure(&e);
}
}
}
PersistenceMsg::RewindPoint(point) => {
if let Err(e) = self.storage.append_rewind_point(&self.info, &point).await {
tracing::warn!(?e, "failed to write rewind point");
if let Err(error) = self.storage.append_rewind_point(&self.info, &point).await {
tracing::warn!(%error, "failed to write rewind point");
self.note_storage_failure(&error);
}
}
PersistenceMsg::TruncateRewindPoints { from_index } => {
@@ -1701,6 +1738,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, from_index, "failed to truncate rewind points");
self.note_storage_failure(&e);
}
}
PersistenceMsg::MergeRewindPointsFrom { target_index } => {
@@ -1710,6 +1748,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, target_index, "failed to merge rewind points");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CollectionId(collection_id) => {
@@ -1719,6 +1758,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write collection id");
self.note_storage_failure(&e);
}
}
PersistenceMsg::NextTraceTurn {
@@ -1731,11 +1771,13 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write next trace turn");
self.note_storage_failure(&e);
}
}
PersistenceMsg::Signals(signals) => {
if let Err(e) = self.storage.write_signals(&self.info, &signals).await {
tracing::warn!(?e, "failed to write session signals");
self.note_storage_failure(&e);
}
}
PersistenceMsg::AnnouncementState(state) => {
@@ -1745,16 +1787,19 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write announcement state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::Feedback(entry) => {
if let Err(e) = self.storage.append_feedback(&self.info, &entry).await {
tracing::warn!(?e, "failed to write feedback entry");
self.note_storage_failure(&e);
}
}
PersistenceMsg::Btw(entry) => {
if let Err(e) = self.storage.append_btw(&self.info, &entry).await {
tracing::warn!(?e, "failed to write btw entry");
self.note_storage_failure(&e);
}
}
PersistenceMsg::GitHead { commit, branch } => {
@@ -1764,6 +1809,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to persist git HEAD");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CompactionCheckpoint(checkpoint) => {
@@ -1773,6 +1819,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write compaction checkpoint file");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CompactionRequest(request) => {
@@ -1782,11 +1829,13 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write compaction request artifact");
self.note_storage_failure(&e);
}
}
PersistenceMsg::RecapRequest(request) => {
if let Err(e) = self.storage.write_recap_request(&self.info, &request).await {
tracing::warn!(?e, "failed to write recap request artifact");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CompactionSegment(segment) => {
@@ -1796,20 +1845,25 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write compaction segment");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CopyFile { one_shot } => {
// Flush pending writes and sync all session files to disk before copying.
self.flush_and_sync().await;
let result = self.copy_session_dir_to_memory().await;
// Not the durability barrier: the write-failure latch stays for FlushAndAck.
let flushed = self.flush_pending().await;
let result = match flushed.and(self.sync_files().await) {
Ok(()) => self.copy_session_dir_to_memory().await,
Err(error) => {
Err(anyhow::Error::new(error).context("session files are not on disk"))
}
};
let _ = one_shot.send(result);
}
}
}
// Drain the merge buffer on channel close.
self.flush_pending().await;
// Drain the merge buffer on channel close; a failure is logged by write_update.
let _ = self.flush_pending().await;
}
async fn copy_session_dir_to_memory(&self) -> anyhow::Result<SessionStateCopy> {
@@ -2002,6 +2056,7 @@ pub(crate) async fn new(
info: info_clone,
storage: storage.clone(),
pending_notification: None,
pending_write_error: None,
rx,
summary: crate::session::summary::SummaryGenerator::new(
crate::session::summary::SummaryConfig {
@@ -2070,6 +2125,7 @@ pub async fn new_with_explicit_dir(
info: info_clone,
storage: storage.clone(),
pending_notification: None,
pending_write_error: None,
rx,
summary: crate::session::summary::SummaryGenerator::new(
crate::session::summary::SummaryConfig {
@@ -2181,6 +2237,7 @@ pub(crate) async fn load_light(
info: loaded_info,
storage: storage.clone(),
pending_notification: None,
pending_write_error: None,
rx,
summary: summary_gen,
registry_title_sync,
@@ -3625,3 +3682,120 @@ mod repo_wide_resolution_tests {
);
}
}
#[cfg(test)]
mod barrier_tests {
use super::*;
fn sampling_client() -> crate::sampling::Client {
crate::sampling::Client::new(kigi_sampler::SamplerConfig {
api_key: Some("test-key".to_string()),
base_url: "http://localhost".to_string(),
model: "test".to_string(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: Default::default(),
context_window: 100_000,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
reasoning_effort: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
doom_loop_recovery: None,
header_injector: None,
})
.expect("sampling client should build")
}
async fn barrier(handle: &PersistenceHandle) -> io::Result<()> {
let (tx, rx) = tokio::sync::oneshot::channel();
handle
.tx
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
.unwrap();
rx.await.unwrap()
}
async fn spawn(dir: &std::path::Path) -> (Info, PersistenceHandle) {
let info = Info {
id: acp::SessionId::new("barrier"),
cwd: dir.to_string_lossy().into_owned(),
};
let handle = new_with_explicit_dir(
&info,
dir.to_path_buf(),
acp::ModelId::new("test-model"),
sampling_client(),
crate::test_support::TEST_MODEL.to_owned(),
)
.await
.unwrap();
(info, handle)
}
/// A chat append that never reached the file fails the next barrier once;
/// the barrier after that is clean again.
#[tokio::test]
async fn flush_and_ack_reports_a_lost_chat_append_once() {
let dir = tempfile::tempdir().unwrap();
let (info, handle) = spawn(dir.path()).await;
barrier(&handle).await.unwrap();
// A directory where the chat log should be makes the append fail.
let chat_file = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf())
.chat_file(&info);
let _ = std::fs::remove_file(&chat_file);
std::fs::create_dir(&chat_file).unwrap();
handle
.tx
.send(PersistenceMsg::Chat(ConversationItem::user("lost")))
.unwrap();
// CopyFile is an ordering fence that keeps the write-failure latch.
let (tx, rx) = tokio::sync::oneshot::channel();
handle
.tx
.send(PersistenceMsg::CopyFile { one_shot: tx })
.unwrap();
let _ = rx.await.unwrap();
// With the path restored only the lost append can fail the barrier.
std::fs::remove_dir(&chat_file).unwrap();
let error = barrier(&handle).await.unwrap_err();
assert_ne!(error.kind(), io::ErrorKind::NotFound, "got {error}");
barrier(&handle).await.unwrap();
}
/// A history rewrite that never landed fails the next barrier.
#[tokio::test]
async fn flush_and_ack_reports_a_failed_history_replacement() {
let dir = tempfile::tempdir().unwrap();
let (info, handle) = spawn(dir.path()).await;
let chat_file = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf())
.chat_file(&info);
// The rewrite stages a sibling temp file; a directory there fails it.
std::fs::create_dir(chat_file.with_extension("jsonl.tmp")).unwrap();
handle
.tx
.send(PersistenceMsg::ReplaceChatHistory(vec![
ConversationItem::user("rewritten"),
]))
.unwrap();
let error = barrier(&handle).await.unwrap_err();
assert_ne!(error.kind(), io::ErrorKind::NotFound, "got {error}");
barrier(&handle).await.unwrap();
}
}
@@ -1020,6 +1020,7 @@ pub(super) async fn build_skill_information_for_refs(
slash_skills: &[SkillInfo],
session_id: &str,
) -> Option<String> {
use kigi_tools::implementations::skills::body_cap::cap_skill_body;
use kigi_tools::implementations::skills::skill::{
SkillRef, SubstitutionContext, apply_substitutions, build_skill_block,
build_skill_information, load_skill_content,
@@ -1034,6 +1035,13 @@ pub(super) async fn build_skill_information_for_refs(
};
match load_skill_content(info).await {
Ok(mut content) => {
if cap_skill_body(&mut content) {
tracing::info!(
skill = %sk.name,
path = %info.path,
"skill body truncated at read cap"
);
}
let skill_dir = std::path::Path::new(&info.path)
.parent()
.and_then(|p| p.to_str());
@@ -1465,6 +1473,31 @@ mod tests {
);
}
/// A 220 KB skill body stops at the read cap; the tail stays on disk.
#[tokio::test]
async fn build_skill_information_for_refs_caps_an_oversized_body() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SKILL.md");
let body = (1..=1100)
.map(|n| format!("{n:05} {}", "x".repeat(194)))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, &body).unwrap();
let mut skill = make_skill("big", true);
skill.path = path.to_string_lossy().to_string();
let skills = vec![skill];
let parsed =
parse_skill_references("/big", &skills, all_gated()).expect("known skill must parse");
let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
.await
.expect("skill body must load");
assert!(info.contains("00001 "), "head must survive");
assert!(!info.contains("01100 "), "tail must be cut");
assert!(info.contains("Skill body truncated at the 25000-token cap"));
}
#[test]
fn resolve_loop_annotates_block_with_compact_display_text() {
let outcome = resolve(
@@ -86,7 +86,7 @@ impl JsonlStorageAdapter {
fn updates_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("updates.jsonl")
}
fn chat_file(&self, info: &Info) -> PathBuf {
pub(crate) fn chat_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("chat_history.jsonl")
}
fn summary_file(&self, info: &Info) -> PathBuf {
@@ -700,7 +700,7 @@ impl JsonlStorageAdapter {
options: super::CopySessionOptions,
) -> io::Result<super::CopySessionResult> {
let target_dir = self.session_dir(target_info);
std::fs::create_dir_all(&target_dir)?;
super::create_dir_all_durable(&target_dir)?;
let source_summary = self.read_summary_sync(source_info)?;
let chat_format_version = source_summary.chat_format_version;
let mut chat_to_copy: Vec<ConversationItem> =
@@ -920,7 +920,7 @@ async fn next_compaction_segment_index(compaction_dir: &std::path::Path) -> u64
impl StorageAdapter for JsonlStorageAdapter {
async fn init_session(&self, info: &Info, model_id: acp::ModelId) -> io::Result<Summary> {
let dir = self.session_dir(info);
std::fs::create_dir_all(&dir)?;
super::create_dir_all_durable(&dir)?;
let summary_path = self.summary_file(info);
if Path::new(&summary_path).exists() {
tracing::info!("Loading existing session from JSONL");
@@ -1283,29 +1283,10 @@ impl StorageAdapter for JsonlStorageAdapter {
.await
}
async fn sync_session_files(&self, info: &Info) -> io::Result<()> {
let info_clone = info.clone();
let adapter_clone = self.clone();
tokio::task::spawn_blocking(move || -> io::Result<()> {
use std::fs::OpenOptions;
let adapter = adapter_clone;
let files_to_sync = [
adapter.updates_file(&info_clone),
adapter.chat_file(&info_clone),
adapter.summary_file(&info_clone),
adapter.plan_file(&info_clone),
adapter.rewind_points_file(&info_clone),
];
for file_path in &files_to_sync {
if file_path.exists()
&& let Ok(file) = OpenOptions::new().write(true).open(file_path)
{
let _ = file.sync_all();
}
}
Ok(())
})
.await
.map_err(io::Error::other)?
let dir = self.session_dir(info);
tokio::task::spawn_blocking(move || super::sync_tree_durable(&dir))
.await
.map_err(io::Error::other)?
}
async fn replace_chat_history(
&self,
@@ -2794,3 +2794,24 @@ async fn graph_mode_state_round_trips_and_tombstones() {
let after = adapter.load_session_without_updates(&info).await.unwrap();
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
}
/// The barrier covers nested state files, and an unsyncable one fails it.
#[cfg(unix)]
#[tokio::test]
async fn sync_session_files_reports_a_nested_file_that_cannot_be_synced() {
use std::os::unix::fs::PermissionsExt;
// Root opens anything; the permission probe is meaningless there.
if unsafe { libc::geteuid() } == 0 {
return;
}
let dir = TempDir::new().unwrap();
let adapter = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf());
let info = create_test_info();
adapter.init_session(&info, default_model_id()).await.unwrap();
let nested = adapter.goal_mode_state_file(&info);
std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
std::fs::write(&nested, b"{}").unwrap();
std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o000)).unwrap();
let error = adapter.sync_session_files(&info).await.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "got {error}");
}
@@ -1,5 +1,5 @@
use async_trait::async_trait;
use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::Path;
use crate::extensions::notification::SessionNotification;
@@ -15,6 +15,137 @@ use agent_client_protocol as acp;
use kigi_sampling_types::ReasoningEffort;
use kigi_workspace::session::file_state::RewindPoint;
/// Fsync `file` to stable media; macOS `fsync` may stop at the drive cache.
#[cfg(target_os = "macos")]
pub(crate) fn sync_file_durable(file: &std::fs::File) -> io::Result<()> {
use std::os::fd::AsRawFd;
file.sync_all()?;
fullfsync_raw(file.as_raw_fd())
}
#[cfg(target_os = "macos")]
fn fullfsync_raw(fd: std::os::fd::RawFd) -> io::Result<()> {
// SAFETY: fcntl on a live descriptor with no pointer arguments.
if unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_file_durable(file: &std::fs::File) -> io::Result<()> {
file.sync_all()
}
/// Fsync `dir` so entries just created or renamed into it survive power loss.
#[cfg(target_os = "macos")]
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
use std::os::fd::AsRawFd;
let file = std::fs::File::open(dir)?;
file.sync_all()?;
fullfsync_raw(file.as_raw_fd())
}
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
std::fs::File::open(dir)?.sync_all()
}
/// Windows has no directory-handle fsync; NTFS journals directory metadata.
#[cfg(windows)]
pub(crate) fn sync_dir_durable(_dir: &Path) -> io::Result<()> {
Ok(())
}
/// Write `bytes` to `tmp`, fsync them, rename over `dest`, fsync the parent:
/// a crash never leaves `dest` torn, and the rename never publishes
/// unsynced bytes over a synced file.
pub(crate) fn write_bytes_atomic(tmp: &Path, dest: &Path, bytes: &[u8]) -> io::Result<()> {
let written = std::fs::File::create(tmp).and_then(|mut file| {
file.write_all(bytes)?;
sync_file_durable(&file)
});
if let Err(error) = written {
let _ = std::fs::remove_file(tmp);
return Err(error);
}
crate::util::fs::replace_file(tmp, dest)?;
sync_parent_dir_durable(dest)
}
fn sync_parent_dir_durable(path: &Path) -> io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?;
sync_dir_durable(parent)
}
fn open_for_sync(path: &Path) -> io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
// FlushFileBuffers needs a writable handle; fsync does not.
options.write(cfg!(windows));
options.open(path)
}
/// Fsync every file and directory under `dir`, `dir` included, so all
/// session state reaches stable media. A path that vanished is not an error.
pub(crate) fn sync_tree_durable(dir: &Path) -> io::Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
for entry in entries {
let entry = entry?;
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
if file_type.is_dir() {
sync_tree_durable(&entry.path())?;
} else if file_type.is_file() {
match open_for_sync(&entry.path()) {
Ok(file) => sync_file_durable(&file)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
}
match sync_dir_durable(dir) {
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
/// `create_dir_all`, then fsync every directory that gained an entry so the
/// chain itself survives power loss. A directory that exists but is still
/// empty re-syncs a few ancestors: an earlier create may have died before
/// its sync.
pub(crate) fn create_dir_all_durable(dir: &Path) -> io::Result<()> {
let mut gaining_an_entry = Vec::new();
let mut cursor = dir;
while !cursor.exists() {
let Some(parent) = cursor.parent() else { break };
gaining_an_entry.push(parent.to_path_buf());
cursor = parent;
}
std::fs::create_dir_all(dir)?;
if gaining_an_entry.is_empty() && std::fs::read_dir(dir)?.next().is_none() {
gaining_an_entry.extend(dir.ancestors().skip(1).take(4).map(Path::to_path_buf));
}
for parent in gaining_an_entry.iter().filter(|parent| !is_fs_root(parent)) {
sync_dir_durable(parent)?;
}
Ok(())
}
fn is_fs_root(path: &Path) -> bool {
path.parent()
.is_none_or(|parent| parent.as_os_str().is_empty())
}
pub mod jsonl;
pub mod search;
pub mod search_fts;
@@ -228,12 +228,13 @@ fn read_summary(path: &Path) -> io::Result<Summary> {
fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()> {
let bytes = serde_json::to_vec_pretty(summary)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?;
// Windows-safe replace: this is the write that persists a session's
// CURRENT MODEL — a bare rename made a switched model silently revert
// on resume whenever AV/indexer held summary.json open on Windows.
crate::util::fs::replace_file(&tmp, summary_path)
// Durable and Windows-safe: this write persists a session's CURRENT MODEL,
// and other writers (`/rename`) race the persistence barrier on this path.
super::write_bytes_atomic(
&summary_path.with_extension("json.tmp"),
summary_path,
&bytes,
)
}
#[cfg(test)]
@@ -912,7 +912,10 @@ fn spawn_with_argv(
if kigi_sandbox::should_restrict_child_network() {
// SAFETY: single prctl syscall (async-signal-safe).
unsafe {
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
cmd.pre_exec(move || {
kigi_sandbox::child_net::install_child_network_filter(filter)
});
}
}
});
@@ -359,6 +359,7 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(&path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -413,6 +414,7 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(&path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -472,6 +474,7 @@ pub async fn save_mcp_server_config_at(
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -552,6 +555,7 @@ pub async fn delete_mcp_server_config_at(
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(path)?;
let tmp = path.with_extension("toml.tmp");
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -10,6 +10,7 @@ mod resolve;
mod settings_writes;
mod tips;
mod worktree;
mod write_dest;
pub use campaigns::{
load_effective_config, load_effective_config_disk_only, persist_models_default,
@@ -30,3 +31,4 @@ pub use resolve::*;
pub use settings_writes::*;
pub use tips::*;
pub use worktree::*;
pub(crate) use write_dest::config_write_dest;
@@ -52,6 +52,7 @@ pub async fn save_config(config: &Config) -> Result<()> {
}
let toml_str = toml::to_string_pretty(&root)?;
let path = super::config_write_dest(&path)?;
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
@@ -0,0 +1,110 @@
//! Where a config write lands when the path is a symlink.
use std::io;
use std::path::{Path, PathBuf};
/// The user's own config.toml keeps its symlink; every other path comes back unchanged.
pub(crate) fn config_write_dest(path: &Path) -> io::Result<PathBuf> {
// With no home, `kigi_home()` is `./.kigi`: the project's own tree, never trusted.
let user_config = kigi_config::user_kigi_home().map(|home| home.join("config.toml"));
write_dest(path, user_config.as_deref())
}
/// A project config is repo-controlled: following its symlink would overwrite any file it names.
fn write_dest(path: &Path, user_config: Option<&Path>) -> io::Result<PathBuf> {
if user_config != Some(path) {
return Ok(path.to_path_buf());
}
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => dunce::canonicalize(path).map_err(|e| {
let context = format!("config symlink {} does not resolve: {e}", path.display());
io::Error::new(e.kind(), context)
}),
Ok(_) => Ok(path.to_path_buf()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(path.to_path_buf()),
Err(e) => Err(e),
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::util::config::{McpServerConfig, save_mcp_server_config_at};
fn symlinked_config(dir: &Path, referent_content: &str) -> (PathBuf, PathBuf) {
let referent = dir.join("dotfiles.toml");
std::fs::write(&referent, referent_content).unwrap();
let link = dir.join("config.toml");
std::os::unix::fs::symlink(&referent, &link).unwrap();
(link, referent)
}
#[test]
fn user_config_symlink_resolves_to_its_referent() {
let dir = tempfile::tempdir().unwrap();
let (link, referent) = symlinked_config(dir.path(), "");
let dest = write_dest(&link, Some(&link)).unwrap();
assert_eq!(dest, dunce::canonicalize(&referent).unwrap());
}
#[test]
fn regular_and_missing_user_config_keep_their_path() {
let dir = tempfile::tempdir().unwrap();
let regular = dir.path().join("config.toml");
std::fs::write(&regular, "").unwrap();
let missing = dir.path().join("absent.toml");
assert_eq!(write_dest(&regular, Some(&regular)).unwrap(), regular);
assert_eq!(write_dest(&missing, Some(&missing)).unwrap(), missing);
}
#[test]
fn dangling_user_config_symlink_is_an_error_not_a_clobber() {
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("config.toml");
std::os::unix::fs::symlink(dir.path().join("gone.toml"), &link).unwrap();
let err = write_dest(&link, Some(&link)).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert!(err.to_string().contains("does not resolve"), "{err}");
}
#[test]
fn project_config_symlink_is_never_followed() {
let dir = tempfile::tempdir().unwrap();
let (link, _referent) = symlinked_config(dir.path(), "");
let user_config = dir.path().join("home").join("config.toml");
assert_eq!(write_dest(&link, Some(&user_config)).unwrap(), link);
}
#[test]
fn nothing_is_followed_when_no_user_home_resolves() {
let dir = tempfile::tempdir().unwrap();
let (link, _referent) = symlinked_config(dir.path(), "");
assert_eq!(write_dest(&link, None).unwrap(), link);
}
#[tokio::test]
async fn project_scope_save_replaces_the_link_and_spares_its_target() {
let dir = tempfile::tempdir().unwrap();
let (link, target) = symlinked_config(dir.path(), "victim = true\n");
let server: McpServerConfig =
toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
save_mcp_server_config_at(&link, "demo", &server)
.await
.unwrap();
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(
meta.file_type().is_file(),
"the link is replaced, not followed"
);
assert_eq!(std::fs::read_to_string(&target).unwrap(), "victim = true\n");
let saved = std::fs::read_to_string(&link).unwrap();
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
}
}
@@ -0,0 +1,35 @@
//! A symlinked `~/.kigi/config.toml` survives a save: the write lands on the referent.
#![cfg(unix)]
use kigi_shell::util::config::{McpServerConfig, save_mcp_server_config};
#[tokio::test]
async fn user_config_symlink_survives_a_save() {
let home = tempfile::tempdir().unwrap();
let dotfiles = tempfile::tempdir().unwrap();
// SAFETY: the only test in this binary; set before `kigi_home()` first runs.
unsafe { std::env::set_var("KIGI_SHARE_DIR", home.path()) };
let referent = dotfiles.path().join("kigi.toml");
std::fs::write(&referent, "[ui]\ntheme = \"dark\"\n").unwrap();
let link = home.path().join("config.toml");
std::os::unix::fs::symlink(&referent, &link).unwrap();
let server: McpServerConfig = toml::from_str("command = \"echo\"\nargs = [\"hi\"]\n").unwrap();
save_mcp_server_config("demo", &server).await.unwrap();
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink(), "the link must survive");
let saved = std::fs::read_to_string(&referent).unwrap();
assert!(saved.contains("[mcp_servers.demo]"), "{saved}");
assert!(saved.contains("theme = \"dark\""), "{saved}");
let leftovers: Vec<_> = std::fs::read_dir(home.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.filter(|name| name != "config.toml")
.collect();
assert!(
leftovers.is_empty(),
"no tmp beside the link: {leftovers:?}"
);
}
@@ -161,6 +161,9 @@ async fn request(handle: &PermissionHandle, access: AccessKind, id: &str) -> Dec
session_id: None,
subagent_type: None,
subagent_description: None,
// The accesses here carry no file path; the process cwd is a real cwd.
tool_cwd: AbsPathBuf::new(std::env::current_dir().unwrap()).unwrap(),
display_cwd: None,
};
let PermissionHandle::Actor { cmd_tx, .. } = handle else {
panic!("expected actor handle");
@@ -144,7 +144,7 @@ impl LeaderStdioClient {
.env("KIGI_TRACE_UPLOAD", "false")
.env("KIGI_INSTRUMENTATION", "disabled")
// Inherited by the spawned leader, whose stderr goes to
// ~/.kigi/leader.log — keep it chatty for diagnosis.
// ~/.kigi/logs/leader.log — keep it chatty for diagnosis.
.env("RUST_LOG", "kigi_shell=debug");
let (mut child, stderr) = spawn_piped_with_stderr_capture(cmd);
@@ -345,5 +345,5 @@ pub async fn wait_for_replay_notifications(
}
pub fn leader_log(home: &Path) -> String {
std::fs::read_to_string(home.join(".kigi").join("leader.log")).unwrap_or_default()
std::fs::read_to_string(home.join(".kigi").join("logs").join("leader.log")).unwrap_or_default()
}
@@ -634,7 +634,8 @@ impl LocalTerminalActor {
#[cfg(target_os = "linux")]
if kigi_sandbox::should_restrict_child_network() {
unsafe {
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter));
}
}
@@ -2646,7 +2647,8 @@ fn spawn_shell_command(
#[cfg(target_os = "linux")]
if kigi_sandbox::should_restrict_child_network() {
unsafe {
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
cmd.pre_exec(move || kigi_sandbox::child_net::install_child_network_filter(filter));
}
}
cmd
@@ -46,6 +46,12 @@ impl ReadFileVersion {
}
}
pub(crate) const MAX_NUM_TOKENS: usize = 25_000;
pub(crate) const MAX_NUM_BYTES: usize =
MAX_NUM_TOKENS * kigi_token_estimation::BYTES_PER_TOKEN as usize;
/// True when `text` is over the per-call token cap.
pub(crate) fn exceeds_read_cap(text: &str) -> bool {
crate::util::truncate::estimate_tokens(text) > MAX_NUM_TOKENS
}
pub const MAX_LINES_READ: usize = 1_000;
pub use crate::implementations::read_file::{
FileMetadata, PDF_MAX_PAGES_PER_READ, bytes_to_metadata, parse_page_range,
@@ -434,22 +440,25 @@ pub(crate) async fn run_read_file(
.map(|t| t.0.max_lines_read())
.unwrap_or_else(|| TruncationConfig::default().max_lines_read())
};
let (effective_offset, effective_limit) = if is_skill_file {
(None, None)
} else {
(
input.offset,
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
)
// A skill file under the cap comes back whole; over it, it reads like any other file.
let skill_full = is_skill_file
.then(|| extract_file_content_lines(&file_content, None, None, total_lines))
.filter(|full| !exceeds_read_cap(&full.content));
let (extracted, stored_offset, stored_limit) = match skill_full {
Some(full) => (full, None, None),
None => (
extract_file_content_lines(
&file_content,
input.offset,
Some(input.limit.unwrap_or(usize::MAX).min(max_lines)),
total_lines,
),
stored_read_offset(input.offset),
input.limit,
),
};
let extracted = extract_file_content_lines(
&file_content,
effective_offset,
effective_limit,
total_lines,
);
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
if !is_skill_file && token_count > MAX_NUM_TOKENS {
if exceeds_read_cap(&extracted.content) {
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
let (grep_name, execute_name);
{
let res = resources.lock().await;
@@ -497,11 +506,6 @@ pub(crate) async fn run_read_file(
};
return Ok(ReadFileOutput::FileTooLarge(msg));
}
let (stored_offset, stored_limit) = if is_skill_file {
(None, None)
} else {
(stored_read_offset(input.offset), input.limit)
};
if let Some(flag) = streamable_out {
*flag = true;
}
@@ -1660,9 +1664,8 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
other => panic!("Expected FileContent, got {:?}", other),
}
}
#[tokio::test]
async fn skill_file_skips_token_limit() {
let tmp = TempDir::new().unwrap();
/// 1100 lines of 200 bytes: about 55k tokens, over the 25k cap.
fn write_oversized_skill(tmp: &TempDir) {
let line = "x".repeat(200);
let big_content = std::iter::repeat_n(line.as_str(), 1100)
.collect::<Vec<_>>()
@@ -1670,6 +1673,11 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
let skill_dir = tmp.path().join("skills");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), &big_content).unwrap();
}
#[tokio::test]
async fn skill_file_over_cap_returns_file_too_large() {
let tmp = TempDir::new().unwrap();
write_oversized_skill(&tmp);
let tool = ReadFileTool;
let mut resources = test_resources(tmp.path());
resources.insert(TemplateRenderer::new(
@@ -1687,11 +1695,36 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
.await
.unwrap();
assert!(
matches!(result, ReadFileOutput::FileContent(_)),
"SKILL.md should not be truncated, got {:?}",
matches!(result, ReadFileOutput::FileTooLarge(_)),
"got {:?}",
std::mem::discriminant(&result),
);
}
#[tokio::test]
async fn skill_file_over_cap_honors_offset_and_limit() {
let tmp = TempDir::new().unwrap();
write_oversized_skill(&tmp);
let tool = ReadFileTool;
let resources = test_resources(tmp.path());
let input = ReadFileInput {
path: "skills/SKILL.md".to_string(),
offset: Some(3),
limit: Some(2),
pages: None,
format: None,
};
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
.await
.unwrap();
match result {
ReadFileOutput::FileContent(fc) => {
assert_eq!((Some(3), Some(2)), (fc.offset, fc.limit));
let line = "x".repeat(200);
assert_eq!(format!("3→{line}\n{line}"), fc.content);
}
other => panic!("Expected FileContent, got {:?}", other),
}
}
#[test]
fn parse_single_page() {
assert_eq!(parse_page_range("3", 10).unwrap(), vec![2]);
@@ -6,6 +6,7 @@
use std::path::Path;
use crate::implementations::skills::body_cap::cap_skill_body;
use crate::implementations::skills::skill::{SkillOutput, extract_skill_body, format_skill_name};
use crate::implementations::skills::types::SkillInfo;
use crate::types::requirements::{Expr, ToolRequirement};
@@ -279,7 +280,7 @@ impl kigi_tool_runtime::Tool for SkillTool {
}
};
let content = match load_skill_content(&skill).await {
let mut content = match load_skill_content(&skill).await {
Ok(c) => c,
Err(e) => {
return Ok(SkillOutput {
@@ -292,6 +293,14 @@ impl kigi_tool_runtime::Tool for SkillTool {
}
};
if cap_skill_body(&mut content) {
tracing::info!(
skill = %skill.name,
path = %skill.path,
"skill body truncated at read cap"
);
}
let files = list_skill_files(&skill, 10).await;
let output = format_output(&skill, &content, &files);
@@ -0,0 +1,59 @@
//! Cap an injected skill body at the file-read token cap.
use crate::implementations::kigi::read_file::{MAX_NUM_BYTES, MAX_NUM_TOKENS, exceeds_read_cap};
use crate::util::truncate::floor_char_boundary;
/// Cuts `body` on a line boundary under the read cap and appends a note; true when it cut.
pub fn cap_skill_body(body: &mut String) -> bool {
if !exceeds_read_cap(body) {
return false;
}
let note = format!(
"[Skill body truncated at the {MAX_NUM_TOKENS}-token cap. Read the rest of the \
skill file with the file read tool using a line offset and limit.]"
);
let budget = MAX_NUM_BYTES.saturating_sub(note.len() + 2);
let end = floor_char_boundary(body, budget);
let cut = body[..end].rfind('\n').filter(|&i| i > 0).unwrap_or(end);
body.truncate(cut);
body.push_str("\n\n");
body.push_str(&note);
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leaves_body_under_cap_untouched() {
let mut body = "# Small skill\n\nDo the thing.".to_owned();
assert!(!cap_skill_body(&mut body));
assert_eq!("# Small skill\n\nDo the thing.", body);
}
#[test]
fn cuts_on_line_boundary_under_cap() {
let mut body = (1..=1100)
.map(|n| format!("{n:05} {}", "x".repeat(194)))
.collect::<Vec<_>>()
.join("\n");
assert!(cap_skill_body(&mut body));
assert!(!exceeds_read_cap(&body));
assert!(!body.contains("01100 "));
assert!(body.contains("offset"));
let (head, _note) = body.rsplit_once("\n\n").expect("note separator");
let last_line = head.rsplit('\n').next().expect("head has a line");
assert_eq!(200, last_line.len());
}
#[test]
fn single_long_line_falls_back_to_char_cut() {
let mut body = "é".repeat(60_000);
assert!(cap_skill_body(&mut body));
assert!(!exceeds_read_cap(&body));
assert!(body.starts_with("éé"));
}
}
@@ -1,3 +1,4 @@
pub mod body_cap;
pub mod discovery;
pub mod skill;
pub mod types;
@@ -8,8 +8,8 @@ const MAX_LINES_READ_DEFAULT: usize = 1_000;
/// There is deliberately no per-line cap: clipping long lines silently
/// corrupts single-line files (minified JSON, data dumps) with no way for
/// the model to recover the clipped bytes. Non-skill reads are bounded by
/// the whole-read `MAX_NUM_TOKENS` cap instead (skill files are exempt from
/// all read limits by design). Other agent CLIs likewise apply no
/// the whole-read `MAX_NUM_TOKENS` cap instead (a skill file under that cap
/// comes back whole, offset and limit ignored). Other agent CLIs likewise apply no
/// per-line cap. The wire field (`TruncationConfig.max_chars_per_line` in
/// kigi-tools.proto) is deprecated and ignored.
#[derive(Debug, Clone, Default)]
+13
View File
@@ -585,6 +585,19 @@ pub fn dup_tui_stderr() -> io::Result<std::fs::File> {
}
}
/// Point fd 2 at `/dev/null` for the rest of the process: the terminal stopped reading, so any further stderr write would block.
pub fn silence_native_stderr() {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let Ok(devnull) = std::fs::OpenOptions::new().write(true).open("/dev/null") else {
return;
};
// SAFETY: devnull is a valid descriptor; fd 2 is a valid target.
unsafe { libc::dup2(devnull.as_raw_fd(), 2) };
}
}
/// Restore fd 2 to point to the real terminal stderr.
///
/// Call this before exiting so that any final messages (panic output,
@@ -15,7 +15,7 @@ kigi --sandbox workspace
# Read-only mode (read everywhere, write only to ~/.kigi/ + temp dirs)
kigi --sandbox read-only
# Most restrictive profile (read CWD + system paths, write CWD + temp dirs + ~/.kigi/, no child network)
# Most restrictive profile (read CWD + system paths, write CWD + temp dirs + ~/.kigi/ state dirs, no child network)
kigi --sandbox strict
```
@@ -29,10 +29,12 @@ kigi --sandbox strict
| `workspace` | Everywhere | CWD + `~/.kigi/` + `/tmp` + `/var/tmp` | Allowed | Normal development |
| `devbox` | Everywhere | All top-level dirs except `/data` | Allowed | Disposable dev VMs |
| `read-only` | Everywhere | `~/.kigi/` + `/tmp` + `/var/tmp` | Blocked¹ | Exploration, code review |
| `strict` | CWD + system paths | CWD + `~/.kigi/` + `/tmp` + `/var/tmp` | Blocked¹ | Untrusted code |
| `strict` | CWD + system paths | CWD + `/tmp` + `/var/tmp` + `~/.kigi/` state dirs² | Blocked¹ | Untrusted code |
¹ Child-network blocking is enforced on **Linux only** (via seccomp). On macOS it is a no-op — these profiles do not restrict child-process network there.
² `run`, `sessions`, `worktrees`, `worktree_pool`, `tasks`, `memory`, `logs`, `debug`, `crash`, `memtrace`, `trace-exports`. The rest of `~/.kigi/` (`config.toml`, `auth.json`, `sandbox.toml`, `hooks/`, `bin/`, `vendor/`) is read-only, so a sandboxed command cannot change the next launch. Root-level caches (`version.json`, `slash-mru.json`, `pager.toml` settings) are not written under `strict`.
To block specific files (e.g. `.env` or credential paths) on top of a profile, define a [custom profile](#custom-profiles) with a `deny` list — it is kernel-enforced (read + write/rename) and supports glob patterns like `**/*.pem`.
### Profile Details
@@ -43,7 +45,7 @@ To block specific files (e.g. `.env` or credential paths) on top of a profile, d
**read-only** -- Use when you want the agent to analyze code without modifying your project files. The agent can read everything but can only write to `~/.kigi/` (needed for session persistence) and temp directories. Child-process network access is blocked on Linux (no-op on macOS).
**strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory and essential system paths. Writes are limited to CWD, `~/.kigi/`, and temp directories. Child-process network access is blocked on Linux (no-op on macOS).
**strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory, essential system paths, and `~/.kigi/`. Writes are limited to CWD, the `~/.kigi/` state directories (sessions, worktrees, memory, logs), and temp directories; `config.toml`, `auth.json`, `hooks/` and `bin/` stay read-only. Child-process network access is blocked on Linux (no-op on macOS).
---
@@ -193,7 +195,7 @@ In practice, on Linux this means:
## Event Logging
Sandbox events are logged to `~/.kigi/sandbox-events.jsonl` for debugging. Events include:
Sandbox events are logged to `~/.kigi/sessions/sandbox-events.jsonl` for debugging. Events include:
- Profile applied (which profile, timestamp)
- Violations (attempted access to denied paths)
@@ -270,10 +270,10 @@ Path patterns are globs matched against the path string the tool was called with
- `*` and `?` do not cross `/`; `**` does. `Read(src/*)` matches `src/main.rs` but not `src/nested/mod.rs`; use `Read(src/**)` for the whole tree.
- A bare filename matches only that exact string. Use `**/.env` to match `.env` at any depth.
- There are no anchor prefixes: a leading `//` or `~/` in a pattern is treated as literal glob text. Write absolute-path patterns or `**/` patterns instead.
- Paths are matched as given, without canonicalization. Whether a path is absolute or relative depends on how the tool was invoked, so patterns intended as boundaries should cover both forms (for example both `/repo/secrets/**` and `secrets/**`).
- Allow rules match the path as the tool was called with it. Deny and ask rules are also checked against the file the tool will open: the path is resolved against the session directory, symlinks are followed, and the absolute and the workspace-relative spelling (with or without a leading `./`) are matched. `secrets/**` therefore catches a read of `/repo/secrets/key` and a read through a symlink that points into `secrets/`. A symlink that cannot be resolved (a loop) prompts when any deny or ask rule covers the tool.
- `Read` rules also govern `grep` searches; `Grep(...)` rules match only grep.
`Read` and `Edit` deny rules additionally apply to file paths that shell commands touch (for example `cat` or `sed` on a denied path), and that shell-level check resolves symlinks. The direct `read_file`/`search_replace` tool checks do not resolve symlinks. For OS-level enforcement that covers every process, combine deny rules with the sandbox ([18-sandbox.md](18-sandbox.md)).
`Read` and `Edit` deny rules additionally apply to file paths that shell commands touch (for example `cat` or `sed` on a denied path), and that shell-level check resolves symlinks too. For OS-level enforcement that covers every process, combine deny rules with the sandbox ([18-sandbox.md](18-sandbox.md)).
### MCP Rules
@@ -45,6 +45,14 @@ pub(super) fn refresh_context_used(view: &mut AgentView, used: u64) {
let total = view.session.models.get_context_window().unwrap_or(0);
view.apply_context_used(used, total);
}
/// The count a compaction lifecycle update carries for the context bar: the banner's "N% full" must not sit next to a stale pre-turn number.
pub(super) fn compaction_context_refresh(update: &XaiSessionUpdate) -> Option<u64> {
match update {
XaiSessionUpdate::AutoCompactStarted { tokens_used, .. } => Some(*tokens_used),
XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } => Some(*tokens_after),
_ => None,
}
}
/// Refresh the bar and record `used` as the confirmed count for a pending
/// compaction message; call only from the `meta.totalTokens` path.
pub(super) fn confirm_context_used(view: &mut AgentView, used: u64) {
@@ -197,8 +205,10 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
&mut agent.scrollback,
is_api_key_auth,
);
if let XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } = update {
refresh_context_used(agent, *tokens_after);
if let Some(used) = compaction_context_refresh(update) {
refresh_context_used(agent, used);
}
if let XaiSessionUpdate::AutoCompactCompleted { .. } = update {
agent.todo.update_todos(Vec::new());
}
changed
@@ -812,6 +822,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
XaiSessionUpdate::ModelChanged {
model_id,
reasoning_effort,
context_window,
} => {
if agent.session.model_switch_pending {
tracing::debug!(
@@ -847,6 +858,8 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
.session
.models
.set_current(new_model_id.clone(), effort);
let window_changed =
context_window.is_some_and(|total| agent.apply_context_window(total));
agent.session.user_model_preference = Some(new_model_id.clone());
let resolved_effort = agent.session.models.reasoning_effort;
let actually_changed =
@@ -858,7 +871,7 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
"ModelChanged broadcast applied (remote switch)"
);
}
actually_changed
actually_changed || window_changed
}
XaiSessionUpdate::MemoryFiles { files } => {
let entries = crate::views::memory_modal::build_entries(files);
@@ -1068,10 +1081,7 @@ pub(super) fn handle_child_session_notification(
| XaiSessionUpdate::AutoCompactFailed { .. }
| XaiSessionUpdate::AutoCompactCancelled { .. }
| XaiSessionUpdate::RetryState(_) => {
let compact_tokens = match &update {
XaiSessionUpdate::AutoCompactCompleted { tokens_after, .. } => Some(*tokens_after),
_ => None,
};
let compact_tokens = compaction_context_refresh(&update);
let mut changed = false;
if let Some(child_view) = agent.subagent_views.get_mut(child_sid) {
changed = apply_session_event(
@@ -1701,12 +1701,14 @@ pub(super) fn model_changed_ext(
session_id: &str,
model_id: &str,
reasoning_effort: Option<&str>,
context_window: Option<u64>,
) -> acp::ExtNotification {
let payload = SessionNotification {
session_id: acp::SessionId::new(session_id),
update: XaiSessionUpdate::ModelChanged {
model_id: model_id.to_string(),
reasoning_effort: reasoning_effort.map(String::from),
context_window,
},
meta: None,
};
@@ -1723,6 +1725,7 @@ pub(super) fn model_changed_ext_with_event(
update: XaiSessionUpdate::ModelChanged {
model_id: model_id.to_string(),
reasoning_effort: None,
context_window: None,
},
meta: Some(serde_json::json!({ "eventId" : event_id })),
};
@@ -110,6 +110,70 @@
);
}
fn seed_windowed_models(agent: &mut AgentView) {
for (slug, window) in [("wide", 1_000_000u64), ("narrow", 262_144)] {
let mut info = make_model_info(slug);
info.meta = serde_json::json!({ "totalContextTokens": window })
.as_object()
.cloned();
agent
.session
.models
.available
.insert(acp::ModelId::new(std::sync::Arc::from(slug)), info);
}
agent
.session
.models
.set_current(acp::ModelId::new(std::sync::Arc::from("wide")), None);
agent.apply_context_used(15_000, 1_000_000);
}
/// A follower mirrors a remote switch and installs the effective window
/// the broadcast carries, like the invoking client does.
#[test]
fn model_changed_broadcast_installs_the_reported_context_window() {
let mut app = make_app_with_agent("sess-1");
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
let notif = model_changed_ext("sess-1", "narrow", None, Some(262_144));
assert!(handle_ext_notification(&notif, &mut app));
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
assert_eq!((snap.used, snap.total, snap.usage_pct), (15_000, 262_144, 6));
}
/// Same model, same effort, new effective window (the catalog was
/// reloaded and re-applied): the follower must still request a redraw.
#[test]
fn model_changed_same_model_new_window_requests_redraw() {
let mut app = make_app_with_agent("sess-1");
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
let notif = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
assert!(handle_ext_notification(&notif, &mut app));
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
assert_eq!((snap.used, snap.total), (15_000, 2_000_000));
let again = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
assert!(!handle_ext_notification(&again, &mut app));
}
/// A broadcast from a leader that predates the field carries no window;
/// the snapshot is left for the next streaming update to refresh.
#[test]
fn model_changed_broadcast_without_window_keeps_the_snapshot() {
let mut app = make_app_with_agent("sess-1");
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
let notif = model_changed_ext("sess-1", "narrow", None, None);
assert!(handle_ext_notification(&notif, &mut app));
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
assert_eq!((snap.used, snap.total), (15_000, 1_000_000));
}
#[test]
fn models_update_without_active_agent_uses_shell_default() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
@@ -232,7 +296,7 @@
let scrollback_before = agent.scrollback.len();
assert!(!agent.session.model_switch_pending);
let notif = model_changed_ext("sess-1", "kigi-4", None);
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
changed,
@@ -278,7 +342,7 @@
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
assert!(!agent.session.model_switch_pending);
let notif = model_changed_ext("sess-1", "auto", None);
let notif = model_changed_ext("sess-1", "auto", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
changed,
@@ -328,7 +392,7 @@
agent.session.model_switch_pending = true;
let scrollback_before = agent.scrollback.len();
let notif = model_changed_ext("sess-1", "kigi-4", None);
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
!changed,
@@ -369,7 +433,7 @@
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None);
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
!changed,
@@ -400,7 +464,7 @@
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"));
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"), None);
assert!(handle_ext_notification(&notif, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
@@ -422,7 +486,7 @@
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
let notif = model_changed_ext("sess-OTHER", "kigi-4", None);
let notif = model_changed_ext("sess-OTHER", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(!changed);
@@ -576,9 +576,8 @@
}
#[test]
fn child_compact_started_does_not_reset_context_used() {
// Sibling variants in the same outer arm must not touch the numerator;
// guards against accidental widening of the AutoCompactCompleted gate.
fn child_compact_started_refreshes_context_used() {
// AutoCompactStarted carries the count the trigger fired on; the child bar must show it.
let mut agent = make_agent(Some("root-sess"));
let child_sid = "child-sess-3";
agent
@@ -603,7 +602,17 @@
let child_view = agent.subagent_views.get(child_sid).unwrap();
assert_eq!(
child_view.context_state.as_ref().map(|c| c.used),
Some(90_000)
Some(95_000)
);
// A sibling variant without a count leaves the numerator alone.
let cancelled = XaiSessionUpdate::AutoCompactCancelled {
reason: "user".into(),
};
let _ = handle_child_session_notification(cancelled, child_sid, &mut agent, false);
let child_view = agent.subagent_views.get(child_sid).unwrap();
assert_eq!(
child_view.context_state.as_ref().map(|c| c.used),
Some(95_000)
);
}
@@ -693,5 +702,30 @@
!session.model_incompatible,
"non-encrypted_content error types must not set model_incompatible"
);
}
/// The banner's count must also refresh the context bar.
#[test]
fn auto_compact_started_refreshes_the_context_bar() {
let mut app = make_app_with_agent("sess-1");
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().apply_context_used(15_000, 262_144);
let payload = SessionNotification {
session_id: acp::SessionId::new("sess-1"),
update: XaiSessionUpdate::AutoCompactStarted {
tokens_used: 90_000,
context_window: 262_144,
percentage: 34,
reason: "threshold".into(),
},
meta: None,
};
let notif = acp::ExtNotification::new(
"kigi/session/update",
std::sync::Arc::from(serde_json::value::to_raw_value(&payload).unwrap()),
);
let _ = handle_ext_notification(&notif, &mut app);
let bar = app.agents[&id].context_state.as_ref().map(|c| (c.used, c.total));
assert_eq!(bar, Some((90_000, 262_144)));
}
+6 -1
View File
@@ -1431,6 +1431,8 @@ pub enum Effect {
Compact {
agent_id: AgentId,
session_id: acp::SessionId,
/// `/compact <instructions>`, forwarded as `userContext`.
user_context: Option<String>,
},
/// Kill a background task.
KillBgTask {
@@ -2112,7 +2114,10 @@ pub enum TaskResult {
agent_id: AgentId,
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
result: Result<(), SwitchModelError>,
/// Ok carries the effective context window the shell reported
/// (`totalContextTokens` in the response meta; `None` from a shell
/// that predates it).
result: Result<Option<u64>, SwitchModelError>,
/// Forwarded from `Effect::SwitchModel.prev_model_id` for
/// rollback on `IncompatibleAgent`.
prev_model_id: Option<acp::ModelId>,
@@ -734,7 +734,8 @@ impl AgentView {
Event::Mouse(mouse) => self.handle_inline_edit_mouse(mouse),
Event::Paste(text) => {
if let Some(ref mut edit) = self.inline_edit {
edit.textarea.insert_str(text);
edit.textarea
.insert_str(&crate::views::prompt_widget::normalize_line_breaks(text));
}
InputOutcome::Changed
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more