Compare commits

..
28 Commits
Author SHA1 Message Date
雷电芽衣 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
116 changed files with 10128 additions and 6429 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 }}
+9 -1
View File
@@ -17,11 +17,19 @@ 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 and through `3794978` (2026-09-09, ≈v1.0.24) in the
0.1.15 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.
## Hard constraints
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
Generated
+67 -64
View File
@@ -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.15"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.13"
version = "0.1.15"
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.15"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.13"
version = "0.1.15"
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.15"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.13"
version = "0.1.15"
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.15"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.13"
version = "0.1.15"
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.15"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.13"
version = "0.1.15"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.13"
version = "0.1.15"
[[package]]
name = "kigi-tty-utils"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"base64",
"chrono",
@@ -8840,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.13"
version = "0.1.15"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.13"
version = "0.1.15"
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/",
};
@@ -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
);
}
}
+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:?}"),
}
}
+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() {
+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.
---
+49 -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);
@@ -9067,6 +9073,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() {
@@ -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,
};
@@ -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");
}
+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,
};
+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();
}
}
@@ -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)
});
}
}
});
@@ -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
+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
}
@@ -1231,6 +1231,17 @@ mod cancel_turn_mouse_tests {
use crate::views::modal::{CancelTurnChoice, CancelTurnViewState};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
#[test]
fn queued_post_flush_escapes_drain_in_order() {
let mut agent = make_agent();
agent.queue_post_flush(crate::terminal::overlay::PostFlush::plain("a".into()));
agent.queue_post_flush(crate::terminal::overlay::PostFlush::plain("b".into()));
let drained = agent
.take_pending_post_flush()
.map(|p| p.as_str().to_owned());
assert_eq!(drained.as_deref(), Some("ab"));
assert!(agent.take_pending_post_flush().is_none());
}
fn make_agent() -> AgentView {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
AgentView::new(
@@ -22,10 +22,7 @@ impl AgentView {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
// Kitty images outlive the dropped viewer state; clear before close.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
self.image_viewer = None;
self.image_load_rx = None;
// The viewer's decoded/re-encoded overlay image (tens of MB
@@ -539,10 +536,7 @@ impl AgentView {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
// Kitty images outlive the dropped viewer state; clear before close.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
self.video_viewer = None;
// The viewer's pre-extracted frame set (~50–300 MB for a
// typical clip) just dropped; return the pages to the OS.
@@ -569,10 +563,7 @@ impl AgentView {
match gboom.handle_key(key) {
crate::gboom::GboomKeyOutcome::Close => {
// Kitty images outlive the dropped game state; clear before close.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
self.gboom = None;
}
crate::gboom::GboomKeyOutcome::Changed => {}
@@ -1003,6 +1003,8 @@ pub struct AgentView {
pub(crate) edit_hl: Option<crate::app::edit_highlight_worker::EditHlRuntime>,
/// Whether any inline media is currently placed on screen.
pub(crate) inline_media_active: bool,
/// Escapes queued for the next frame's post-flush write (see `queue_post_flush`).
pub(crate) pending_post_flush: Option<crate::terminal::overlay::PostFlush>,
/// Image IDs that were placed on screen last frame. Used to detect
/// images that scrolled off and need their Kitty placements cleared.
pub(crate) last_placed_ids: HashSet<u32>,
@@ -344,7 +344,10 @@ impl AgentView {
self.prompt.textarea.begin_undo_group();
group_open = true;
}
if matches!(self.prompt.handle_paste(&to_insert), PromptEvent::Edited) {
if matches!(
self.prompt.insert_dropped_path(&to_insert),
PromptEvent::Edited
) {
inserted_non_image = true;
}
}
@@ -605,13 +605,32 @@ impl AgentView {
false,
&mut Vec::new(),
);
child_post_flush = post_flush;
// The child's own queued clears (inline image ids) go out before its frame escapes.
child_post_flush = match (child_view.take_pending_post_flush(), post_flush) {
(Some(mut pending), Some(frame)) => {
pending.append(frame);
Some(pending)
}
(pending, frame) => pending.or(frame),
};
}
(None, child_post_flush)
}
pub fn should_show_tip(&mut self) -> bool {
false
}
/// Queue escapes for the frame's post-flush write: the event loop never writes stderr inline.
pub(crate) fn queue_post_flush(&mut self, escapes: crate::terminal::overlay::PostFlush) {
match self.pending_post_flush.as_mut() {
Some(pending) => pending.append(escapes),
None => self.pending_post_flush = Some(escapes),
}
}
pub(crate) fn take_pending_post_flush(
&mut self,
) -> Option<crate::terminal::overlay::PostFlush> {
self.pending_post_flush.take()
}
/// `area` is the screen region assigned to this agent view.
/// When a tracing overlay is visible, this is smaller than `f.area()`.
///
@@ -669,20 +688,17 @@ impl AgentView {
|| self.active_modal.is_some())
{
self.inline_media_active = false;
kigi_shell::util::with_locked_stderr(|stderr| {
for &id in self.inline_media_ids.values() {
let clear = crate::terminal::image::clear_kitty_image(id);
let _ = std::io::Write::write_all(stderr, clear.as_bytes());
}
});
let mut clears = String::new();
for &id in self.inline_media_ids.values() {
clears.push_str(&crate::terminal::image::clear_kitty_image(id));
}
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(clears));
self.inline_media_ids.clear();
self.inline_media_iterm_emitted.clear();
}
if let Some(ref child_sid) = self.active_subagent.clone() {
if let Some(esc) = self.take_own_inline_media_clear_escapes() {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = std::io::Write::write_all(stderr, esc.as_bytes());
});
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(esc));
}
return self.draw_subagent_fullscreen(
&child_sid.clone(),
@@ -695,9 +711,7 @@ impl AgentView {
);
}
if let Some(esc) = self.take_subagent_inline_media_clear_escapes() {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = std::io::Write::write_all(stderr, esc.as_bytes());
});
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(esc));
}
let appearance = self.scrollback.appearance().clone();
let layout_cfg = &appearance.scrollback.layout;
@@ -185,6 +185,7 @@ impl AgentView {
mermaid: None,
edit_hl: None,
inline_media_active: false,
pending_post_flush: None,
last_placed_ids: HashSet::new(),
last_terminal_size: (0, 0),
terminal_size_stale: false,
@@ -733,6 +734,20 @@ impl AgentView {
}
}
}
/// The shell reported the session's effective context window (model
/// switch). The snapshot caches the previous model's window, so the
/// header would show it until the next streaming update. Returns
/// whether the snapshot changed, so the caller can request a redraw.
pub fn apply_context_window(&mut self, total: u64) -> bool {
let Some((used, cached)) = self.context_state.as_ref().map(|c| (c.used, c.total)) else {
return false;
};
if cached == total {
return false;
}
self.apply_context_used(used, total);
true
}
/// Record a key event to the input flight recorder.
///
/// Zero heap allocations — stores raw `Copy` types in the ring buffer.
@@ -1262,6 +1277,36 @@ mod resolve_turn_activity_tests {
}
}
#[cfg(test)]
mod apply_context_window_tests {
use super::super::test_agent_view;
fn view() -> super::AgentView {
test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
}
#[test]
fn reported_window_replaces_the_cached_total() {
let mut agent = view();
agent.apply_context_used(15_000, 1_000_000);
assert!(agent.apply_context_window(262_144));
let snap = agent.context_state.as_ref().unwrap();
assert_eq!(
(snap.used, snap.total, snap.usage_pct, snap.free_tokens),
(15_000, 262_144, 6, 262_144 - 15_000)
);
}
#[test]
fn same_window_reports_no_change() {
let mut agent = view();
agent.apply_context_used(15_000, 1_000_000);
assert!(!agent.apply_context_window(1_000_000));
}
#[test]
fn no_snapshot_stays_none() {
let mut agent = view();
assert!(!agent.apply_context_window(262_144));
assert!(agent.context_state.is_none());
}
}
#[cfg(test)]
mod status_window_tests {
use super::super::test_agent_view;
#[test]
+28 -12
View File
@@ -954,6 +954,8 @@ pub struct AppView {
/// Whether the pager uses fullscreen (alt-screen) or inline mode.
/// Set from the resolved terminal state at startup.
pub(crate) screen_mode: super::ScreenMode,
/// Out-of-band escapes ride the frame queue, never an inline stderr write.
pub(crate) escape_writer: crate::render::draw::EscapeWriter,
/// Agent Dashboard state. `Some(_)` only when the dashboard view
/// is active (`active_view == AgentDashboard`) or recently closed.
/// Held outside the `ActiveView` discriminant because `DashboardState`
@@ -1022,7 +1024,10 @@ impl AppView {
scroll_state: MouseScrollState::default(),
scroll_config: ScrollConfig::from_settings(),
appearance: AppearanceConfig::default(),
notification_service: NotificationService::new(Default::default()),
notification_service: NotificationService::new(
Default::default(),
crate::render::draw::EscapeWriter::disconnected(),
),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
@@ -1054,6 +1059,7 @@ impl AppView {
welcome_auth_fallback_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(
@@ -3095,9 +3101,8 @@ impl AppView {
.hyperlink_capabilities()
.osc22_cursor
{
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = crossterm::execute!(stderr, crate::terminal::SetDefaultCursor);
});
self.escape_writer
.emit_command(crate::terminal::SetDefaultCursor);
}
}
let want_mouse_off = self.auth_show_raw_url
@@ -3106,17 +3111,15 @@ impl AppView {
&& matches!(self.auth_state, AuthState::Authenticating { .. });
if want_mouse_off && !self.auth_mouse_disabled {
self.auth_mouse_disabled = true;
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = crossterm::execute!(stderr, crossterm::event::DisableMouseCapture);
});
self.escape_writer
.emit_command(crossterm::event::DisableMouseCapture);
#[cfg(windows)]
super::win_native_selection::enable_native_selection();
super::MOUSE_CAPTURE_ENABLED.store(false, std::sync::atomic::Ordering::Release);
} else if !want_mouse_off && self.auth_mouse_disabled {
self.auth_mouse_disabled = false;
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = crossterm::execute!(stderr, crossterm::event::EnableMouseCapture);
});
self.escape_writer
.emit_command(crossterm::event::EnableMouseCapture);
super::MOUSE_CAPTURE_ENABLED.store(true, std::sync::atomic::Ordering::Release);
for agent in self.agents.values_mut() {
agent.set_sticky_toast_recursive(None);
@@ -3450,6 +3453,8 @@ impl AppView {
panel.render(full_area, f.buffer_mut());
}
let (cursor_pos, post_flush) = result;
let post_flush =
Self::merge_post_flush(agent.take_pending_post_flush(), post_flush);
let has_cloud = false;
if has_cloud || self.import_claude_modal.is_some() {
link_spans.clear();
@@ -3505,7 +3510,7 @@ impl AppView {
dashboard,
|inner, buf| {
if let Some(agent) = agents.get_mut(&agent_id) {
agent.draw(
let (cursor, post_flush) = agent.draw(
inner,
buf,
registry,
@@ -3517,6 +3522,13 @@ impl AppView {
bundle_state,
false,
link_spans,
);
(
cursor,
Self::merge_post_flush(
agent.take_pending_post_flush(),
post_flush,
),
)
} else {
(None, None)
@@ -4304,7 +4316,10 @@ pub(crate) mod tests {
scroll_state: MouseScrollState::default(),
scroll_config: ScrollConfig::default(),
appearance: AppearanceConfig::default(),
notification_service: NotificationService::new(Default::default()),
notification_service: NotificationService::new(
Default::default(),
crate::render::draw::EscapeWriter::disconnected(),
),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
@@ -4373,6 +4388,7 @@ pub(crate) mod tests {
welcome_auth_fallback_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(
@@ -1290,10 +1290,9 @@ pub(super) fn handle_compact_complete(
}
Err(err) => {
tracing::error!(agent = ?agent_id, error = %err, "Compaction failed");
// Already sanitized by the effect layer.
agent.scrollback.push_block(RenderBlock::session_event(
SessionEvent::CompactionFailed {
error: String::new(),
},
SessionEvent::CompactionFailed { error: err.clone() },
));
}
}
@@ -162,6 +162,12 @@ fn format_cron_prompt(prompt: &str, task_id: &str, human_schedule: &str) -> Stri
kigi_tools::reminders::format_scheduled_task_prompt(prompt, task_id, human_schedule)
}
/// `/compact <instructions>` text from a queued command; a queue edit may leave leading whitespace.
pub(super) fn compact_user_context(text: &str) -> Option<String> {
let inv = crate::slash::parse_invocation(text.trim_start())?;
let args = inv.args.trim();
(inv.token == "compact" && !args.is_empty()).then(|| args.to_string())
}
/// Try to send the next queued entry (prompt, command, bash, or cron) if the agent is idle.
///
/// Called after enqueue operations and task completions to advance the queue.
@@ -392,10 +398,12 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
agent.end_work_announced = false;
agent.session.start_command(AgentCommand::Compact);
agent.turn_started_at = Some(Instant::now());
let user_context = compact_user_context(&queued.text);
vec![Effect::Compact {
agent_id,
session_id,
user_context,
}]
}
QueueEntryKind::BashCommand => {
@@ -978,13 +978,13 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete(
agent_id: AgentId,
model_id: acp::ModelId,
effort: Option<ReasoningEffort>,
result: Result<(), SwitchModelError>,
result: Result<Option<u64>, SwitchModelError>,
prev_model_id: Option<acp::ModelId>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.session.model_switch_pending = false;
let mut effects = match result {
Ok(()) => {
Ok(context_window) => {
agent.session.user_model_preference = Some(model_id.clone());
let display_name = agent
.session
@@ -996,6 +996,9 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete(
let prev_model = agent.session.models.current.clone();
let prev_effort = agent.session.models.reasoning_effort;
agent.session.models.set_current(model_id.clone(), effort);
if let Some(total) = context_window {
agent.apply_context_window(total);
}
let resolved_effort = agent.session.models.reasoning_effort;
let unchanged =
prev_model.as_ref() == Some(&model_id) && prev_effort == resolved_effort;
@@ -506,13 +506,14 @@ pub(in crate::app::dispatch) fn dispatch_toggle_mouse_capture(app: &mut AppView)
"active_view": format!("{:?}", app.active_view),
})),
);
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = if enable {
crossterm::execute!(stderr, crossterm::event::EnableMouseCapture)
} else {
crossterm::execute!(stderr, crossterm::event::DisableMouseCapture)
};
});
// Through the writer queue, never inline: see EscapeWriter.
if enable {
app.escape_writer
.emit_command(crossterm::event::EnableMouseCapture);
} else {
app.escape_writer
.emit_command(crossterm::event::DisableMouseCapture);
}
// On legacy conhost, DisableMouseCapture restores the *pre-capture* stdin
// mode, which may itself have QuickEdit off (per-window profile or a
// stale mode from a crashed run) — assert it so "mouse off" actually
@@ -463,6 +463,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
None,
wrap_text.as_deref(),
None,
&app.escape_writer,
);
let effects = drain_clipboard_target(&target, app);
maybe_show_x11_primary_paste_hint(
@@ -81,7 +81,10 @@ fn test_app() -> AppView {
scroll_state: crate::input::mouse::MouseScrollState::default(),
scroll_config: crate::input::mouse::ScrollConfig::default(),
appearance: crate::appearance::AppearanceConfig::default(),
notification_service: crate::notifications::NotificationService::new(Default::default()),
notification_service: crate::notifications::NotificationService::new(
Default::default(),
crate::render::draw::EscapeWriter::disconnected(),
),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
@@ -153,6 +156,7 @@ fn test_app() -> AppView {
welcome_auth_fallback_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(
@@ -1789,7 +1789,7 @@ fn switch_model_holds_prompt_until_complete() {
agent_id: id,
model_id,
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -1810,7 +1810,13 @@ fn slash_compact_enqueues_command() {
let effects = dispatch(Action::SendPrompt("/compact".into()), &mut app);
// /compact enqueues as Command and drains immediately (agent was idle).
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::Compact { .. }));
assert!(matches!(
&effects[0],
Effect::Compact {
user_context: None,
..
}
));
// Prompt should be cleared.
assert!(app.agents[&id].prompt.text().is_empty());
}
@@ -1853,6 +1859,19 @@ fn palette_dispatch_preserves_prompt_draft() {
);
}
/// An edited queue row keeps its text verbatim, so leading whitespace must not drop the instructions.
#[test]
fn compact_user_context_survives_leading_whitespace_from_a_queue_edit() {
use crate::app::dispatch::queue::compact_user_context;
assert_eq!(
compact_user_context(" /compact focus on auth").as_deref(),
Some("focus on auth")
);
assert_eq!(compact_user_context("\n/compact x ").as_deref(), Some("x"));
assert_eq!(compact_user_context("/compact"), None);
assert_eq!(compact_user_context("/other args"), None);
}
#[test]
fn slash_compact_with_context_enqueues_command() {
let mut app = test_app_with_agent();
@@ -1861,7 +1880,13 @@ fn slash_compact_with_context_enqueues_command() {
&mut app,
);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::Compact { .. }));
assert!(matches!(
&effects[0],
Effect::Compact {
user_context: Some(ctx),
..
} if ctx == "focus on auth"
));
}
#[test]
@@ -282,7 +282,7 @@ fn model_switch_pending_resets_correctly_across_success_and_failure() {
agent_id: id,
model_id: model_a,
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -535,7 +535,7 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -554,6 +554,96 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
));
}
/// The bare `/model <name>` path: `SetDefaultModel` moves `models.current`
/// before the ACP round trip; the completion then installs the effective
/// window the shell reported, so the header repaints at once.
#[test]
fn switch_model_complete_installs_the_reported_context_window() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let model = |slug: &str, window: u64| {
let mid = acp::ModelId::new(std::sync::Arc::from(slug));
let info = acp::ModelInfo::new(mid.clone(), slug.to_string()).meta(
serde_json::json!({ "totalContextTokens": window })
.as_object()
.cloned(),
);
(mid, info)
};
let (wide, wide_info) = model("wide", 1_000_000);
let (narrow, narrow_info) = model("narrow", 262_144);
{
let agent = app.agents.get_mut(&id).unwrap();
agent
.session
.models
.available
.insert(wide.clone(), wide_info);
agent
.session
.models
.available
.insert(narrow.clone(), narrow_info);
agent.session.models.set_current(wide.clone(), None);
agent.apply_context_used(15_000, 1_000_000);
}
let effects = dispatch(Action::SetDefaultModel(narrow.clone()), &mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SwitchModel { .. }))
);
assert!(app.agents[&id].session.model_switch_pending);
assert_eq!(
app.agents[&id].context_state.as_ref().unwrap().total,
1_000_000
);
dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: narrow,
effort: None,
result: Ok(Some(262_144)),
prev_model_id: Some(wide),
}),
&mut app,
);
let agent = app.agents.get_mut(&id).unwrap();
let snap = agent.context_state.as_ref().unwrap();
assert_eq!(
(snap.used, snap.total, snap.usage_pct),
(15_000, 262_144, 6)
);
let area = ratatui::layout::Rect::new(0, 0, 100, 40);
let mut buf = ratatui::buffer::Buffer::empty(area);
agent.draw(
area,
&mut buf,
&crate::actions::ActionRegistry::defaults(),
&mut crate::scrollback::render::ScratchBuffer::new(),
None,
false,
0,
None,
&crate::app::bundle::BundleState::default(),
false,
&mut Vec::new(),
);
let screen: String = (0..area.height)
.map(|y| {
(0..area.width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(screen.contains("15K / 262K"), "header: {screen:?}");
assert!(!screen.contains("1.0M"), "stale window: {screen:?}");
}
#[test]
fn switch_model_complete_skips_message_and_persist_when_unchanged() {
let mut app = test_app_with_agent();
@@ -575,7 +665,7 @@ fn switch_model_complete_skips_message_and_persist_when_unchanged() {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -630,7 +720,7 @@ fn switch_model_complete_persists_resolved_effort_from_catalog_meta() {
model_id: model_id.clone(),
// user typed `/model Blackbox 4.7` with no effort
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -696,7 +786,7 @@ fn switch_to_non_reasoning_model_clears_persisted_effort() {
agent_id: id,
model_id: model_id.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -930,7 +1020,7 @@ fn same_agent_type_switch_no_modal() {
agent_id: id,
model_id: model_b.clone(),
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
@@ -969,7 +1059,7 @@ fn switch_model_pending_lifecycle() {
agent_id: id,
model_id,
effort: None,
result: Ok(()),
result: Ok(None),
prev_model_id: None,
}),
&mut app,
+23 -8
View File
@@ -1388,13 +1388,18 @@ pub(crate) fn execute(
}
});
}
Effect::Compact { agent_id, session_id } => {
Effect::Compact {
agent_id,
session_id,
user_context,
} => {
let tx = acp_tx.clone();
tasks
.spawn(async move {
let params = serde_json::json!(
{ "sessionId" : session_id.0.to_string(), }
);
let params = serde_json::json!({
"sessionId": session_id.0.to_string(),
"userContext": user_context,
});
let req = acp::ExtRequest::new(
"kigi/compact_conversation",
serde_json::value::to_raw_value(&params)
@@ -1404,9 +1409,14 @@ pub(crate) fn execute(
let result = acp_send(req, &tx).await;
TaskResult::CompactComplete {
agent_id,
result: result
.map(|_| ())
.map_err(|e| sanitize_user_error(&e.to_string())),
// The decoded first line only: diagnostic lines (request URLs) never reach the banner.
result: result.map(|_| ()).map_err(|e| {
sanitize_user_error(
&kigi_shell::sampling::error::user_facing_compact_error(
&kigi_shell::sampling::error::acp_error_message(&e),
),
)
}),
}
});
}
@@ -1587,7 +1597,12 @@ pub(crate) fn execute(
.meta(meta);
let result = acp_send(req, &tx)
.await
.map(|_| ())
.map(|resp| {
resp.meta
.as_ref()
.and_then(|m| m.get("totalContextTokens"))
.and_then(serde_json::Value::as_u64)
})
.map_err(|e| {
use kigi_shell::agent::config::ModelSwitchIncompatibleAgentError;
if let Some(typed) = ModelSwitchIncompatibleAgentError::from_acp_error(
+40 -15
View File
@@ -222,7 +222,7 @@ fn suspend_for_child(
reader_parked: &std::sync::atomic::AtomicBool,
input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crossterm::event::Event>,
run_child: impl FnOnce(),
) -> Option<(u16, u16)> {
) -> std::io::Result<Option<(u16, u16)>> {
use std::sync::atomic::Ordering;
// Pause the reader thread, then wait for a FRESH park (reader provably out of
// crossterm) so the main thread is the sole poll/read caller; bounded so a
@@ -237,8 +237,13 @@ fn suspend_for_child(
// fullscreen, before LeaveAlternateScreen below — mirroring teardown's
// "no late frame after LeaveAlternateScreen" drain). Bounded like the
// reader park so a wedged pty can't hang the suspend.
// The alt-screen writes below take the stderr lock a parked writer still holds: abort instead.
if !writer_sync.wait_drained(Duration::from_millis(750)) {
tracing::warn!("suspend: frame writer not drained within 750ms; proceeding");
input_paused.store(false, Ordering::Release);
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"terminal writer did not drain before suspend",
));
}
// Pre-child cursor probe (minimal only — minimal's startup already proved
// this terminal answers CPR). Reader is parked, so the reply is ours.
@@ -274,7 +279,7 @@ fn suspend_for_child(
// lands in the channel, not the tty.
while input_rx.try_recv().is_ok() {}
input_paused.store(false, Ordering::Release);
moved_cursor
Ok(moved_cursor)
}
/// Restore the inline/minimal live region after a tty-taking child exited.
@@ -351,7 +356,7 @@ fn run_pending_suspends(
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
let moved_cursor = suspend_for_child(
let moved_cursor = match suspend_for_child(
app.screen_mode,
&writer_sync,
input_paused,
@@ -360,7 +365,15 @@ fn run_pending_suspends(
|| {
let _ = std::process::Command::new(&editor).arg(&path).status();
},
);
) {
Ok(moved) => moved,
Err(error) => {
tracing::warn!(%error, "editor suspend skipped");
app.pending_agents_modal_refresh = None;
app.show_toast("Editor not opened: the terminal is not draining output");
return;
}
};
if let Some(tab) = app.pending_agents_modal_refresh.take()
&& let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
@@ -387,7 +400,7 @@ fn run_pending_suspends(
.filter(|p| !p.trim().is_empty())
.unwrap_or_else(|| "less".to_string());
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
let moved_cursor = suspend_for_child(
let moved_cursor = match suspend_for_child(
app.screen_mode,
&writer_sync,
input_paused,
@@ -431,7 +444,15 @@ fn run_pending_suspends(
.status();
}
},
);
) {
Ok(moved) => moved,
Err(error) => {
tracing::warn!(%error, "pager suspend skipped");
let _ = std::fs::remove_file(&path);
app.show_toast("Transcript not opened: the terminal is not draining output");
return;
}
};
let _ = std::fs::remove_file(&path);
// The pager owned the screen; re-anchor if it printed inline (cat) and
// repaint the full viewport rather than diffing against a screen state
@@ -492,6 +513,7 @@ pub(crate) async fn run(
// poll the leader roster (see the roster-poll arm below).
app.leader_mode = connection.leader_status_rx.is_some();
app.screen_mode = term_state.screen_mode;
app.escape_writer = terminal.backend_mut().writer().escape_writer();
// Agent/dashboard prompts pick the mode up at their creation sites
// (`apply_app_scoped_gates` / `ensure_dashboard_state`); the welcome prompt
// already exists, so inject here.
@@ -737,12 +759,15 @@ pub(crate) async fn run(
cursor: compat.cursor.sessions,
};
// Load notification config from [ui.notifications] in config.toml.
if let Some(ref raw) = effective_config {
app.notification_service = crate::notifications::NotificationService::new(
crate::notifications::load_notification_config(raw),
);
}
// Load notification config from [ui.notifications] in config.toml; the
// service always gets the live escape writer, never AppView::new's stub.
app.notification_service = crate::notifications::NotificationService::new(
effective_config
.as_ref()
.map(crate::notifications::load_notification_config)
.unwrap_or_default(),
app.escape_writer.clone(),
);
// Full layered resolve (env/requirements/remote may beat plain `[ui]`).
crate::appearance::cache::set_show_thinking_blocks(
@@ -1378,7 +1403,7 @@ pub(crate) async fn run(
let want_gboom_keyboard = app.gboom_active();
if want_gboom_keyboard {
if !gboom_keyboard_pushed {
super::push_gboom_keyboard_flags();
super::push_gboom_keyboard_flags(&app.escape_writer);
gboom_keyboard_pushed = true;
}
// Only the active game receives release events; any other open
@@ -1386,7 +1411,7 @@ pub(crate) async fn run(
// no key down when reopened after a tab/view switch.
app.gboom_release_backgrounded_games();
} else if gboom_keyboard_pushed {
super::pop_gboom_keyboard_flags();
super::pop_gboom_keyboard_flags(&app.escape_writer);
gboom_keyboard_pushed = false;
// No game is the active input target now (switched to a non-game
// view); clear every game's holds for the same reason.
@@ -290,6 +290,27 @@ mod tests {
agent
}
/// The previous-message editor is its own paste route; separators normalize there too.
#[test]
fn inline_edit_paste_normalizes_line_separators() {
let mut agent = agent_with_prompt();
assert!(agent.enter_inline_edit(0));
let registry = crate::actions::ActionRegistry::defaults();
agent.handle_input(
&crossterm::event::Event::Paste("a\u{2028}b\u{2029}c".into()),
&registry,
);
let text = agent
.inline_edit
.as_ref()
.expect("editing state")
.textarea
.text()
.to_owned();
assert!(text.contains("a\nb\nc"), "{text:?}");
assert!(!text.contains('\u{2028}'), "{text:?}");
}
/// Entering edit mode on a plain user prompt captures the original text,
/// resolves the rewind target, and selects the entry.
#[test]
+199 -37
View File
@@ -73,23 +73,19 @@ static GBOOM_KEYBOARD_PUSHED: AtomicBool = AtomicBool::new(false);
/// release events — required to track several keys held at once. No-op
/// unless the Kitty keyboard protocol is active. Balanced by
/// [`pop_gboom_keyboard_flags`] (and by `restore_terminal` on teardown).
pub(crate) fn push_gboom_keyboard_flags() {
pub(crate) fn push_gboom_keyboard_flags(writer: &crate::render::draw::EscapeWriter) {
if !kitty_flags_pushed() || GBOOM_KEYBOARD_PUSHED.swap(true, Ordering::AcqRel) {
return;
}
let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PushKeyboardEnhancementFlags(flags));
});
writer.emit_command(event::PushKeyboardEnhancementFlags(flags));
}
/// Pop the extra keyboard layer pushed by [`push_gboom_keyboard_flags`].
pub(crate) fn pop_gboom_keyboard_flags() {
pub(crate) fn pop_gboom_keyboard_flags(writer: &crate::render::draw::EscapeWriter) {
if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
});
writer.emit_command(event::PopKeyboardEnhancementFlags);
}
}
/// Tracks whether mouse capture (the five DEC modes enabled by
@@ -175,6 +171,7 @@ pub(crate) const MOUSE_OFF_HINT_PROMPT: &str =
/// `write()` to stderr / the pty fd, keeping the tokio event loop free
/// from pty back-pressure (e.g. when Ghostty is busy with another pane).
pub use crate::render::draw::PagerTerminal;
use crate::render::draw::WriterJoin;
/// Whether the pager uses the alternate screen (fullscreen) or stays inline.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ScreenMode {
@@ -305,14 +302,19 @@ fn resolve_hunk_tracker_mode(
/// pager skips the welcome screen and immediately loads that session (replaying
/// its history). Sessions not found locally are restored from remote storage.
///
/// Returns `Ok(true)` when the user accepted a pending update. The caller
/// should print a message telling the user to relaunch `kigi`.
/// How the pager exited: whether the user accepted a pending update (the
/// caller relaunches `kigi`) and whether the terminal was still reading at
/// teardown (when not, further terminal output must be skipped).
pub struct RunExit {
pub quit_for_update: bool,
pub terminal_reading: bool,
}
pub async fn run(
args: PagerArgs,
bg_update_rx: Option<
tokio::sync::oneshot::Receiver<Option<kigi_update::auto_update::UpdateAvailable>>,
>,
) -> anyhow::Result<bool> {
) -> anyhow::Result<RunExit> {
kigi_tty_utils::redirect_native_stderr();
let screen_mode_override = screen_mode_relaunch::take_screen_mode_env_override();
let cancel = CancellationToken::new();
@@ -580,33 +582,58 @@ pub async fn run(
)
.await;
crate::unified_log::flush_blocking().await;
let _ = restore_terminal(terminal, writer_thread, screen_mode);
// After a timed-out writer join the terminal is not reading: exit hints would block on stderr.
let terminal_reading = !matches!(
restore_terminal(terminal, writer_thread, screen_mode),
Ok(WriterJoin::TimedOut)
);
cancel.cancel();
kigi_tty_utils::global_process_scope().kill_all();
match result {
Ok(run_result) => {
if run_result.quit_for_update {
return Ok(true);
return Ok(RunExit {
quit_for_update: true,
terminal_reading,
});
}
if let Some(relaunch) = run_result.relaunch.as_ref() {
if !terminal_reading {
// The replacement would inherit a silenced stderr and never show.
tracing::warn!("screen-mode relaunch skipped: the terminal stopped reading");
return Ok(RunExit {
quit_for_update: false,
terminal_reading,
});
}
if let Err(e) = screen_mode_relaunch::exec_screen_mode_relaunch(
&relaunch.session_id,
relaunch.minimal,
) {
tracing::error!(error = % e, "screen-mode relaunch failed");
print_relaunch_failure_hint(
&e,
&relaunch.session_id,
relaunch.minimal,
&mut io::stderr(),
);
if terminal_reading {
print_relaunch_failure_hint(
&e,
&relaunch.session_id,
relaunch.minimal,
&mut io::stderr(),
);
}
}
return Ok(false);
return Ok(RunExit {
quit_for_update: false,
terminal_reading,
});
}
if let Some(info) = run_result.exit_info {
if let Some(info) = run_result.exit_info
&& terminal_reading
{
print_exit_resume_hint(&info.session_id, info.minimal, &mut io::stderr());
}
Ok(false)
Ok(RunExit {
quit_for_update: false,
terminal_reading,
})
}
Err(e) => Err(e),
}
@@ -1065,15 +1092,23 @@ fn init_terminal(
kigi_crash_handler::disable_terminal_escape_restore();
})
}
/// Drop the terminal (closing the writer mpsc channel) and join the
/// writer thread. After this returns, subsequent direct stderr writes
/// are guaranteed to land strictly after every queued frame.
/// Drop the terminal and join the writer within `WRITER_JOIN_GRACE`; after `TimedOut` the writer may still hold the stderr lock, so callers must not take it unbounded.
fn drain_writer_thread_before_teardown(
terminal: PagerTerminal,
writer_thread: crate::render::draw::WriterThread,
) {
) -> WriterJoin {
drop(terminal);
writer_thread.join();
let join = writer_thread.join_within(crate::render::draw::WRITER_JOIN_GRACE);
if join == WriterJoin::TimedOut {
crate::unified_log::warn(
"term.writer.join_timeout",
None,
Some(serde_json::json!({
"grace_ms": crate::render::draw::WRITER_JOIN_GRACE.as_millis() as u64
})),
);
}
join
}
/// Inline teardown escape sequences in the canonical order, shared by
/// `restore_terminal` and `set_panic_hook` so the on-wire byte order is
@@ -1104,7 +1139,12 @@ fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option<
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::DisableFocusChange);
});
pop_gboom_keyboard_flags();
// Teardown runs after the writer is gone, so this pop is inline.
if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
});
}
if crate::terminal::take_kitty_flags_pushed() {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
@@ -1134,15 +1174,57 @@ fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option<
#[cfg(windows)]
win_native_selection::restore_stdin_mode();
}
/// Consumes `terminal` and `writer_thread`: queues a final clear in
/// fullscreen mode, drains the writer thread, then emits the inline
/// teardown sequences. The drain ordering guarantees no late frame can
/// land after `LeaveAlternateScreen`.
/// Bound on teardown writes when the stderr lock may be wedged (panic hook; a timed-out writer join).
const TEARDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
/// Run a best-effort teardown on a helper thread for at most `grace`; on timeout it is detached (the process is exiting), inline if no thread can spawn.
fn run_bounded_teardown(f: impl FnOnce() + Send + 'static, grace: std::time::Duration) -> bool {
let slot = std::sync::Arc::new(parking_lot::Mutex::new(Some(f)));
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let worker_slot = std::sync::Arc::clone(&slot);
let spawned = std::thread::Builder::new()
.name("bounded-teardown".into())
.spawn(move || {
if let Some(f) = worker_slot.lock().take() {
f();
}
let _ = done_tx.send(());
});
match spawned {
Ok(_) => done_rx.recv_timeout(grace).is_ok(),
Err(_) => {
if let Some(f) = slot.lock().take() {
f();
}
true
}
}
}
/// Clear (fullscreen), drain the writer, then emit the teardown sequences; `TimedOut` means the terminal is not reading and further stderr writes may block.
fn restore_terminal(
terminal: PagerTerminal,
writer_thread: crate::render::draw::WriterThread,
mode: ScreenMode,
) -> io::Result<WriterJoin> {
let join = restore_terminal_with(
terminal,
writer_thread,
mode,
drain_writer_thread_before_teardown,
emit_terminal_teardown_sequences,
)?;
if join == WriterJoin::TimedOut {
// Later exit output (hints, relaunch notice, update result, error report) must not block on a stalled tty.
kigi_tty_utils::silence_native_stderr();
}
Ok(join)
}
fn restore_terminal_with(
mut terminal: PagerTerminal,
writer_thread: crate::render::draw::WriterThread,
mode: ScreenMode,
) -> io::Result<()> {
drain: impl FnOnce(PagerTerminal, crate::render::draw::WriterThread) -> WriterJoin,
teardown: impl FnOnce(ScreenMode, Option<u16>) + Send + 'static,
) -> io::Result<WriterJoin> {
if mode.is_fullscreen() {
let _ = terminal.clear();
{
@@ -1151,14 +1233,24 @@ fn restore_terminal(
}
}
let inline_cursor_row = (!mode.is_fullscreen()).then(|| terminal.viewport_area().bottom());
drain_writer_thread_before_teardown(terminal, writer_thread);
emit_terminal_teardown_sequences(mode, inline_cursor_row);
let join = drain(terminal, writer_thread);
// A clean join proves past writes landed, not that the terminal still reads: every teardown write is bounded.
let torn_down = run_bounded_teardown(move || teardown(mode, inline_cursor_row), TEARDOWN_GRACE);
if !torn_down {
tracing::warn!(
grace_ms = TEARDOWN_GRACE.as_millis() as u64,
"terminal teardown did not finish in time; detaching"
);
}
drain_pending_events_with_timeout(std::time::Duration::from_millis(10));
let _ = terminal::disable_raw_mode();
signal_handler::mark_restored();
kigi_crash_handler::disable_terminal_escape_restore();
kigi_tty_utils::restore_native_stderr();
Ok(())
if join == WriterJoin::TimedOut || !torn_down {
return Ok(WriterJoin::TimedOut);
}
Ok(WriterJoin::Joined)
}
pub(crate) fn set_terminal_title(title: &str) {
let full = terminal_title_string(title);
@@ -1183,18 +1275,88 @@ fn terminal_title_string(title: &str) -> String {
fn set_panic_hook(mode: ScreenMode) {
let hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
emit_terminal_teardown_sequences(mode, None);
// The writer thread may hold the stderr lock in a blocked tty write.
let torn_down = run_bounded_teardown(
move || emit_terminal_teardown_sequences(mode, None),
TEARDOWN_GRACE,
);
let _ = terminal::disable_raw_mode();
signal_handler::mark_restored();
kigi_crash_handler::disable_terminal_escape_restore();
kigi_tty_utils::restore_native_stderr();
kigi_tty_utils::global_process_scope().kill_all();
if !torn_down {
// The previous hook prints to stderr, which is not reading; keep the log report only.
kigi_tty_utils::silence_native_stderr();
tracing::error!(panic = %info, "process panicked while the terminal was stalled");
return;
}
hook(info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
fn test_terminal_and_writer_thread() -> (PagerTerminal, crate::render::draw::WriterThread) {
let (frame_tx, writer_sync, writer_thread) = crate::render::draw::spawn_writer_thread();
let backend =
CrosstermBackend::new(crate::render::draw::TermWriter::new(frame_tx, writer_sync));
let terminal = kigi_ratatui_inline::Terminal::with_options(
backend,
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 10)),
},
)
.expect("fixed-viewport terminal");
(terminal, writer_thread)
}
/// After a timed-out writer join the teardown must be bounded: the writer may hold the stderr lock.
#[test]
fn restore_bounds_teardown_after_a_timed_out_writer_join() {
let (terminal, writer_thread) = test_terminal_and_writer_thread();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let started = std::time::Instant::now();
let result = restore_terminal_with(
terminal,
writer_thread,
ScreenMode::Inline,
|terminal, writer_thread| {
drop(terminal);
drop(writer_thread);
WriterJoin::TimedOut
},
move |_, _| {
let _ = release_rx.recv();
},
);
assert_eq!(result.expect("restore"), WriterJoin::TimedOut);
assert!(
started.elapsed() < TEARDOWN_GRACE + std::time::Duration::from_secs(5),
"teardown must be detached at the grace deadline"
);
let _ = release_tx.send(());
}
/// A clean join followed by a teardown that never returns still reports a stalled terminal.
#[test]
fn restore_reports_a_stalled_terminal_when_teardown_times_out_after_a_clean_join() {
let (terminal, writer_thread) = test_terminal_and_writer_thread();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let result = restore_terminal_with(
terminal,
writer_thread,
ScreenMode::Inline,
|terminal, writer_thread| {
drop(terminal);
drop(writer_thread);
WriterJoin::Joined
},
move |_, _| {
let _ = release_rx.recv();
},
);
assert_eq!(result.expect("restore"), WriterJoin::TimedOut);
let _ = release_tx.send(());
}
/// `[ui].cursor_blink` tri-state → startup cursor policy; the `None`
/// default must be Inherit (emit nothing).
#[test]
@@ -0,0 +1,33 @@
//! Stderr lines for paths where fd 2 may be dead: a closed pane fails every write, `eprintln!` panics on it, and under `panic = "abort"` that is a SIGABRT plus a crash report.
use std::io::Write;
/// Write `line` and a newline to `w`; `false` when the write failed.
pub fn write_line(w: &mut impl Write, line: &str) -> bool {
writeln!(w, "{line}").is_ok()
}
/// [`write_line`] to process stderr, outcome discarded.
pub fn eprint_line(line: &str) {
write_line(&mut std::io::stderr(), line);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_line_appends_a_newline_and_reports_success() {
let mut buf = Vec::new();
assert!(write_line(&mut buf, "Finishing session…"));
assert_eq!(buf, "Finishing session…\n".as_bytes());
}
/// A closed read end fails every write with EPIPE, like the dead tty a closed pane leaves.
#[test]
fn write_line_reports_failure_on_a_dead_pipe() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
drop(reader);
assert!(!write_line(&mut writer, "unreachable"));
}
}
+12 -9
View File
@@ -4,6 +4,7 @@
//! `spawn_kigi_shell`, sends the ACP lifecycle (init → auth → session → prompt),
//! streams text to stdout, and exits cleanly via `CancellationToken`.
use crate::best_effort_stderr::eprint_line;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@@ -459,7 +460,7 @@ impl HeadlessEmitter {
fn on_error(&self, message: &str) {
match self.format {
OutputFormat::Plain => eprintln!("{message}"),
OutputFormat::Plain => eprint_line(message),
OutputFormat::StreamingJson | OutputFormat::Json => {
let mut err = serde_json::json!({"type":"error","message": message});
if let Some(usage) = &self.usage {
@@ -1276,7 +1277,7 @@ pub async fn run_single_turn(
== Some("max_turns_reached");
if is_max_turns {
match emitter.format {
OutputFormat::Plain => eprintln!("Max turns reached"),
OutputFormat::Plain => eprint_line("Max turns reached"),
OutputFormat::StreamingJson => {
println!("{}", serde_json::json!({"type": "max_turns_reached"}))
}
@@ -1644,7 +1645,9 @@ fn handle_ext_notification(
);
}
OutputFormat::Plain => {
eprintln!("Auto-compacting conversation ({percentage}% full)...");
eprint_line(&format!(
"Auto-compacting conversation ({percentage}% full)..."
));
}
OutputFormat::Json => {}
},
@@ -1652,7 +1655,7 @@ fn handle_ext_notification(
OutputFormat::StreamingJson => {
println!("{}", serde_json::json!({"type": "auto_compact_completed"}));
}
OutputFormat::Plain => eprintln!("Conversation compacted."),
OutputFormat::Plain => eprint_line("Conversation compacted."),
OutputFormat::Json => {}
},
XaiUpdate::AutoCompactFailed { error } => match format {
@@ -1664,9 +1667,9 @@ fn handle_ext_notification(
}
OutputFormat::Plain => {
if error.trim().is_empty() {
eprintln!("Auto-compact failed.");
eprint_line("Auto-compact failed.");
} else {
eprintln!("Auto-compact failed: {error}");
eprint_line(&format!("Auto-compact failed: {error}"));
}
}
OutputFormat::Json => {}
@@ -1675,7 +1678,7 @@ fn handle_ext_notification(
OutputFormat::StreamingJson => {
println!("{}", serde_json::json!({"type": "auto_compact_cancelled"}));
}
OutputFormat::Plain => eprintln!("Auto-compact cancelled."),
OutputFormat::Plain => eprint_line("Auto-compact cancelled."),
OutputFormat::Json => {}
},
XaiUpdate::AutoContinueCompleted { total_tokens } => match format {
@@ -1685,7 +1688,7 @@ fn handle_ext_notification(
serde_json::json!({"type": "auto_continue_completed", "total_tokens": total_tokens})
);
}
OutputFormat::Plain => eprintln!("Resumed after compaction."),
OutputFormat::Plain => eprint_line("Resumed after compaction."),
OutputFormat::Json => {}
},
XaiUpdate::ImageCompressed { message } => match format {
@@ -1695,7 +1698,7 @@ fn handle_ext_notification(
serde_json::json!({"type": "image_compressed", "message": message})
);
}
OutputFormat::Plain => eprintln!("{message}"),
OutputFormat::Plain => eprint_line(&message),
OutputFormat::Json => {}
},
XaiUpdate::SubagentSpawned { subagent_id } => {
+1
View File
@@ -5,6 +5,7 @@
pub mod acp;
pub mod actions;
pub mod app;
pub mod best_effort_stderr;
pub mod client_identity;
pub mod completions_cmd;
mod config_toml_edit;
@@ -42,10 +42,15 @@ pub struct NotificationService {
/// `true` after the first notification; cleared via
/// [`clear_permission_notification`] when the queue drains to empty.
permission_notified: bool,
/// Notification, title and progress escapes ride the frame queue (see `EscapeWriter`).
escape_writer: crate::render::draw::EscapeWriter,
}
impl NotificationService {
pub fn new(config: NotificationConfig) -> Self {
pub fn new(
config: NotificationConfig,
escape_writer: crate::render::draw::EscapeWriter,
) -> Self {
let terminal_ctx = crate::terminal::terminal_context();
let protocol = resolve_protocol(config.method, terminal_ctx);
let focus_tracker = focus::FocusTracker::new(
@@ -64,6 +69,7 @@ impl NotificationService {
progress_active: false,
progress_last_sent: None,
permission_notified: false,
escape_writer,
}
}
@@ -111,6 +117,7 @@ impl NotificationService {
&event.title,
&event.body,
self.terminal_ctx,
&self.escape_writer,
);
}
}
@@ -132,13 +139,7 @@ impl NotificationService {
self.clear_progress_into(&mut buf);
}
if !buf.is_empty() {
kigi_shell::util::with_locked_stderr(|stderr| {
use std::io::Write;
let _ = stderr.write_all(buf.as_bytes());
let _ = stderr.flush();
});
}
self.escape_writer.emit(buf);
}
/// Build escape sequences to set the title and progress bar to idle
@@ -206,22 +207,9 @@ impl NotificationService {
pub fn shutdown(&mut self) {
// Reset the tab title back to "kigi" so it doesn't linger on the
// last activity label after exit.
let title_esc = self.title_manager.reset();
kigi_shell::util::with_locked_stderr(|stderr| {
use std::io::Write as _;
let _ = stderr.write_all(title_esc.as_bytes());
let _ = stderr.flush();
});
let mut buf = String::new();
let mut buf = self.title_manager.reset();
self.clear_progress_into(&mut buf);
if !buf.is_empty() {
kigi_shell::util::with_locked_stderr(|stderr| {
use std::io::Write as _;
let _ = stderr.write_all(buf.as_bytes());
let _ = stderr.flush();
});
}
self.escape_writer.emit(buf);
}
/// Returns `true` if a terminal notification for `ApprovalRequired` has
@@ -281,6 +269,7 @@ impl NotificationService {
progress_active: false,
progress_last_sent: None,
permission_notified: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
}
}
}
@@ -661,6 +650,19 @@ mod tests {
assert!(!svc.is_progress_active());
}
/// Shutdown escapes ride the writer queue, never an inline stderr write.
#[test]
fn shutdown_queues_the_title_reset_on_the_writer() {
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let mut svc = NotificationService::new(
NotificationConfig::default(),
crate::render::draw::EscapeWriter::new(tx, crate::render::draw::WriterSync::new()),
);
svc.shutdown();
let payload = rx.try_recv().expect("shutdown must queue its escapes");
assert!(payload.starts_with(b"\x1b]"), "{payload:?}");
}
#[test]
fn shutdown_clears_active_progress() {
let mut svc = NotificationService::new_for_test(NotificationConfig {
@@ -1,5 +1,4 @@
use std::borrow::Cow;
use std::io::Write;
use crate::notifications::tmux;
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
@@ -68,6 +67,7 @@ pub fn emit_notification(
title: &str,
body: &str,
ctx: &TerminalContext,
writer: &crate::render::draw::EscapeWriter,
) {
// For body-only protocols (OSC 9, OSC 99), fold the title (session
// name) into the body so it's visible. For OSC 777 (Ghostty), the
@@ -81,23 +81,13 @@ pub fn emit_notification(
NotificationProtocol::None => return,
};
// Notifications fire from the event-loop thread: queued, never an inline stderr write.
if ctx.is_tmux_backed() {
let wrapped = tmux::tmux_passthrough(&sequence);
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = stderr.write_all(wrapped.as_bytes());
let _ = stderr.flush();
});
writer.emit(tmux::tmux_passthrough(&sequence));
} else if matches!(protocol, NotificationProtocol::Bel) {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = stderr.write_all(BEL_BYTE);
let _ = stderr.flush();
});
writer.emit(BEL_BYTE);
} else {
let bytes = sequence.as_bytes();
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = stderr.write_all(bytes);
let _ = stderr.flush();
});
writer.emit(sequence.as_bytes());
}
}
@@ -292,31 +282,61 @@ mod tests {
#[test]
fn emit_none_is_noop() {
let ctx = ctx_with_brand(TerminalName::KigiDesktop);
emit_notification(NotificationProtocol::None, "title", "body", &ctx);
emit_notification(
NotificationProtocol::None,
"title",
"body",
&ctx,
&crate::render::draw::EscapeWriter::disconnected(),
);
}
#[test]
fn emit_bel_does_not_panic() {
let ctx = ctx_with_brand(TerminalName::Unknown);
emit_notification(NotificationProtocol::Bel, "", "", &ctx);
emit_notification(
NotificationProtocol::Bel,
"",
"",
&ctx,
&crate::render::draw::EscapeWriter::disconnected(),
);
}
#[test]
fn emit_osc9_does_not_panic() {
let ctx = ctx_with_brand(TerminalName::Iterm2);
emit_notification(NotificationProtocol::Osc9, "title", "body", &ctx);
emit_notification(
NotificationProtocol::Osc9,
"title",
"body",
&ctx,
&crate::render::draw::EscapeWriter::disconnected(),
);
}
#[test]
fn emit_osc99_does_not_panic() {
let ctx = ctx_with_brand(TerminalName::Kitty);
emit_notification(NotificationProtocol::Osc99, "title", "body", &ctx);
emit_notification(
NotificationProtocol::Osc99,
"title",
"body",
&ctx,
&crate::render::draw::EscapeWriter::disconnected(),
);
}
#[test]
fn emit_osc777_does_not_panic() {
let ctx = ctx_with_brand(TerminalName::Ghostty);
emit_notification(NotificationProtocol::Osc777, "title", "body", &ctx);
emit_notification(
NotificationProtocol::Osc777,
"title",
"body",
&ctx,
&crate::render::draw::EscapeWriter::disconnected(),
);
}
#[test]
@@ -10,8 +10,7 @@ use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, Slas
/// Online docs landing page (hardcoded like other TUI deep-links; the
/// project README is the docs entry point for the repo-hosted docs).
pub const BUILD_DOCS_URL: &str =
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/src/branch/main/README.md";
pub const BUILD_DOCS_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/blob/main/README.md";
/// Open How-to Guides or online Build docs.
pub struct DocsCommand;
@@ -6,8 +6,7 @@ use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Where feedback goes: the project's own issue tracker. Kigi is a community
/// build, so its feedback belongs on its own repo — mirroring the official
/// kimi-cli, whose `/feedback` opens its repo's issues page.
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";
/// Open the Kigi issue tracker in the browser.
pub struct FeedbackCommand;
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more