Compare commits

..
76 Commits
Author SHA1 Message Date
ZacharyZhang-NY 781fb44659 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.16 dependency set
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 5s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
`cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md` (cargo-about
0.9.2) on the 0.1.16 lock: kigi crates at 0.1.16, rustls 0.23.45,
rustls-webpki 0.103.15, aws-lc-rs 1.18.1, aws-lc-sys 0.45.0. The set of
listed crates is otherwise unchanged; the large diff is block order.
2026-09-19 04:21:53 -04:00
ZacharyZhang-NY a640816c38 chore(release): bump workspace version to 0.1.16 2026-09-19 04:20:50 -04:00
ZacharyZhang-NY 256ff2528e test(config): two e2e routing tests stop racing KIGI_CODE_BASE_URL
`e2e_default_model_with_session_routes_to_proxy` and
`e2e_duplicate_model_field_both_entries_survive` assert the default
model's base URL. They were not serial and read `KIGI_CODE_BASE_URL`
through `EndpointsConfig`, while two serial tests (auth_method.rs,
models_fetch.rs) point that variable at wiremock. When the runs
overlapped, the base URL came back as the wiremock URI: the Linux suite
failed in 2 of 13 full runs. Presetting the variable fails both tests
every time, on v0.1.15 as well.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01A6RcwHhhsFYYrNdQB4ASuP
2026-09-01 01:05:22 -04:00
ZacharyZhang-NYandClaude Fable 5 39cb1ca1bc chore(release): bump workspace version to 0.1.13
Release / prepare draft release (push) Successful in 6s
Release / build (aarch64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish release (push) Canceled after 0s
Release / build (x86_64-unknown-linux-gnu) (push) Successful in 16m32s
Release / build (aarch64-unknown-linux-gnu) (push) Successful in 16m13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:43:58 -04:00
ZacharyZhang-NYandClaude Fable 5 e83610db6a chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.13 dependency set
cargo-about over the updated lockfile (h2 0.4.19, webbrowser 1.2.4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:09:34 -04:00
ZacharyZhang-NYandClaude Fable 5 fd5cff2de6 chore(gates): finish the Windows sweep — zero clippy findings workspace-wide
Second layer of platform gaps the mac/linux-only gate history left:
unix-only test imports/helpers now cfg-gated (mermaid, fast-worktree,
hunk-tracker, pager-render link test, three pty_e2e module decls),
clippy-suggested cleanups in Windows-cfg code (file-utils classifier,
tui console-mode restore, shared clipboard arboard-error flow, tools
terminal closure, workspace capability tail), and `cargo update` of h2
(RUSTSEC-2026-0258) and webbrowser (RUSTSEC-2026-0257) so `cargo deny
check advisories` passes again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:04:32 -04:00
ZacharyZhang-NYandClaude Fable 5 427e8b6fea docs(agents): record the upstream sync baseline and the subagent spawn bound
The 0.1.13 sync had to reconstruct the fork point by content
fingerprinting; the sync record (baseline snapshot, last-evaluated
upstream commit, and where upstream hides its per-change lists) now
lives in AGENTS.md so the next sync starts from facts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:55:19 -04:00
ZacharyZhang-NYandClaude Fable 5 50669bf302 chore(gates): zero clippy warnings and a buildable sandbox example on Windows
The full-workspace gates had never run on this platform: collapsible
ifs in the Windows shell probe, a same-type cast in the crash handler,
an unused AsRawHandle import, and the sandbox smoke-test example
building its unix-only body (libc, kernel enforcement) unconditionally.
The example now stubs main off-unix; formatting reflow from cargo fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:55:19 -04:00
ZacharyZhang-NYandClaude Fable 5 25d1a4f980 fix(sampler): retry Cloudflare 52x and other 5xx during outages
Port of upstream grok-build 1.0.0/1.0.1-window retry policy.

- is_retryable_api_status: 429 plus any 5xx except Cloudflare 525/526
  (origin TLS handshake / invalid certificate — never clears on its
  own), replacing the 500|502|503|504|520 allowlist so the 521-524,
  529, and 530 edge pages an outage serves actually retry.
- The generic retry path clamps a server Retry-After to
  MAX_RETRY_BACKOFF (30s) and jitters it: Cloudflare 52x pages ask for
  60-120s, which across the budget stalled a turn ~28 minutes. The 429
  path keeps the full (parse-capped) Retry-After under its low attempt
  cap, and jitter is shared via jittered().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:51:36 -04:00
ZacharyZhang-NYandClaude Fable 5 a441b23a6d fix(mcp): kigi mcp add <name> <url> infers an HTTP server
Port of upstream grok-build 1.0.9. A bare positional http(s):// URL
with no transport flag was silently added as a stdio command whose
"command" was the URL. It now infers the HTTP transport with a notice;
extra args, --env, legacy --command/--url, and every explicit flag keep
their prior meaning, and scheme-less URL-looking commands still stay
stdio with the existing warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:42:47 -04:00
ZacharyZhang-NYandClaude Fable 5 574122884a fix(windows): resolve the home directory via the standard lookup
Port of upstream grok-build 1.0.4/1.0.5. Two paths read $HOME only,
which is usually unset on Windows outside Git Bash:

- the global-gitignore fallback (prompt/ignore.rs) now uses
  std::env::home_dir(), the crate-wide idiom;
- fast-worktree's resolve_kigi_home falls back to std::env::home_dir()
  with the same dunce canonicalization, eliminating the documented
  divergence from kigi_config::default_kigi_home. On Unix home_dir()
  reads $HOME first, so behavior is unchanged where HOME is set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:34:45 -04:00
ZacharyZhang-NYandClaude Fable 5 97de661554 fix(startup): thread-exhaustion resilience — cap workers, never panic
Port of upstream grok-build 0.2.113/0.2.114 fixes, extended to Kigi's
startup order.

- kigi-tty-utils runtime module: worker-cap policy (8 max) — tokio's
  per-core default pins 100+ thread slots on many-core shared hosts
  against RLIMIT_NPROC / pids.max, and later spawns die with EAGAIN.
- kigi-bin builds the main runtime with capped workers and reports an
  out-of-threads host with a diagnostic + exit(1) instead of a panic.
- The pre-runtime optimization threads (HTTP warmup, early model
  prefetch) spawn recoverably and degrade — their panic would have
  preempted the runtime's own diagnostic.
- The per-session actor thread and its runtime report spawn/build
  failure through the existing acp::Error / AgentBuildError boundaries
  (new RuntimeBuild variant) instead of panicking the agent host.
- fsnotify's watcher thread spawn maps into FsNotifyError::WatcherStart
  so callers degrade instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:28:17 -04:00
ZacharyZhang-NYandClaude Fable 5 bf93091416 fix(agents): bound how many subagents one session runs at once
Port of upstream grok-build 1.0.1 (landed upstream 2026-08-06). The
coordinator fire-and-forgot every spawn, so a wide fan-out (graph
waves, swarm bursts, model-driven Task storms) could exhaust file
descriptors.

- kigi-tools task/admission: SubagentLimits — 32 concurrent per session
  by default (KIGI_MAX_CONCURRENT_SUBAGENTS), queue|fail behavior
  (KIGI_SUBAGENT_LIMIT_BEHAVIOR), zero clamped to 1, read once at the
  composition root; parse_positive_env helper.
- Each parent session gets one spawn semaphore (map entries live for
  the process so a resumed session shares its predecessor's limit);
  handle_subagent_request holds an owned permit for the whole child
  run — queue awaits FIFO with a unified-log notice at saturation,
  fail routes through send_pre_spawn_failure. After a queued wait, a
  closed parent command channel (teardown) fails the spawn instead of
  running against a dead parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:09:24 -04:00
ZacharyZhang-NYandClaude Fable 5 26d26e15c9 fix(exec): bounded post-kill reaps — D-state children can't wedge a turn
Port of upstream grok-build 1.0.1 (landed upstream 2026-08-06).

A child wedged in an uninterruptible kernel syscall (D-state) never
observes SIGKILL, and a background descendant can hold the output pipes
open past the kill; both previously stalled tool futures without bound.

- kigi-tty-utils: KILL_REAP_TIMEOUT (5s) + reap_killed_bounded — wait
  for an already-killed child at most the bound, then abandon the corpse
  to tokio's orphan reaper.
- local_terminal: pipe readers share their buffer with the spawner and
  join under the reap bound on both exit arms (aborted readers are
  awaited so no tail bytes race the take); the whole tree is enrolled in
  a ProcessGroup and group-killed on timeout; the timeout arm returns
  the synthetic result instead of waiting on the corpse. Grandchild
  kill + prompt-return pinned by a ported unix regression test.
- kigi-tools: reap_killed_search_child wrapper; grep's four kill sites
  reap bounded; glob's stderr goes to null (an unread pipe blocks rg on
  warning backpressure) and its truncation kill reaps bounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 10:45:17 -04:00
ZacharyZhang-NYandClaude Fable 5 1ae803519e fix(compaction): configurable summarizer tool_choice; wider overflow detection
Port of upstream grok-build 0.2.105 + 0.2.118.

- CompactionToolChoice (auto default | none), resolved env
  KIGI_COMPACTION_TOOL_CHOICE > [features].compaction_tool_choice >
  remote settings. Some OpenAI-compatible servers reject
  tool_choice:"none" with tools attached, failing every long-session
  compaction; auto keeps them working (the summarization prompt already
  forbids tool use). Threaded from agent + subagent spawn through
  CompactionConfig into generate_session_compact; the Responses gate
  counts hosted tools too, so a hosted-only request still carries the
  configured choice.
- is_context_length_error recognizes the "current message ... exceeds
  budget" phrasing, so that overflow is Fatal instead of burning the
  retry budget on a deterministic failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 10:22:18 -04:00
ZacharyZhang-NYandClaude Fable 5 6a4bff0d45 fix(agent): a racing cancel can no longer wedge the session's turn slot
Port of upstream grok-build 0.2.103. session/prompt runs an async intake
preamble before enqueueing SessionCommand::Prompt on the actor mailbox; a
concurrent cancel could enqueue Cancel first, land on an idle actor as a
no-op, and the prompt then started a turn nothing would ever cancel.

The per-session prompt-intake lock becomes the dispatch lock: prompt()
holds it across intake as before, and cancel() now holds it around its
Cancel send, so a cancel orders behind the prompt it targets. Regression
test ported verbatim (unix-only harness, like its siblings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 09:53:09 -04:00
ZacharyZhang-NYandClaude Fable 5 6e0464fa74 fix(auth): unique tmp per auth.json write; tighten loose credential perms
Port of upstream grok-build 0.2.113-window fixes:
- write_auth_json_atomic tmp path gains a monotonic sequence next to the
  pid, so two concurrent in-process writers (background mint + proactive
  refresher) cannot share one tmp path and tear each other's bytes; a tmp
  write that fails partway is reclaimed instead of orphaned.
- New secure_file::ensure_owner_only_permissions (0o600 / Windows ACL)
  tightens pre-existing world-readable credential files on read, backup,
  write, replace, and restore. Best-effort with warns after durable
  content — a chmod-only failure must not masquerade as a failed write —
  and propagating in restore_prior_bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 09:31:21 -04:00
ZacharyZhang-NYandClaude Fable 5 257caed650 Add kigicli.dev frontend worker with built site assets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pFJn6ycA4hPRwM9sqasi6
2026-08-25 23:04:06 -04:00
ZacharyZhang-NYandClaude Opus 5 3e1652ca98 ci: drop the inert GitHub workflows
Gitea ignores `.github/workflows/` entirely once `.gitea/workflows/` is
non-empty, so these two files have had no effect since the release workflow
landed — and the GitHub repo they targeted is gone (404, suspended account),
so they cannot run there either.

They were not merely dead: warm-cache.yml's Monday cron kept firing on the
Gitea side before `.gitea/` existed, queueing jobs against GitHub-hosted
labels (`macos-14`, `windows-2022`, `ubuntu-24.04-arm`) that no self-hosted
runner advertises. Those sat queued forever, and its one dispatchable leg
failed on `rustup: command not found` — the same bare-image gap the Gitea
workflow now provisions around.

`.gitea/workflows/release.yml` is the only pipeline; docs/RELEASE.md already
documents it as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:11:28 -04:00
ZacharyZhang-NYandClaude Opus 5 479fb8cbbd chore(release): bump workspace version to 0.1.12
Release / prepare draft release (push) Successful in 5s
Release / build (aarch64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-apple-darwin) (push) Failing after 2s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish release (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Successful in 18m14s
Release / build (x86_64-unknown-linux-gnu) (push) Successful in 17m20s
Every crate inherits `workspace.package.version`, so the single edit plus
the refreshed lockfile carries the release. The `v0.1.12` tag is what
triggers the build; release.yml gates the tag against this value, and
`cargo metadata --locked` passes so the build's `--locked` will too.

This is the first release cut on Gitea Actions. v0.1.6 through v0.1.9 were
all cancelled for want of a runner whose label matched anything, so nothing
has ever published from the new forge — `releases/latest` still 404s, which
is exactly why `kigi update` finds nothing today. Ships the link migration
in the previous commit, so the installers and self-updater published here
point at Gitea instead of a GitHub repo that no longer serves them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:13:19 -04:00
ZacharyZhang-NYandClaude Opus 5 9fb82291b2 chore(migration): repoint every published link off GitHub
The canonical repo is git.zacharyzhang.com now, so every URL a user can
actually reach had to move with it.

Self-updater: `PRODUCTION_ENDPOINTS.update_base_url` targets Gitea's
`/api/v1/repos/.../releases`, which serves the same wire format GitHub's
releases API did — `Release`/asset deserialisation is unchanged. The alpha
channel requests `?per_page=30&limit=30` because GitHub reads the page size
from `per_page` and Gitea from `limit`, and each ignores the other's, so one
URL stays correct on either.

Installers: same API base, and the documented one-liners become
`https://kigicli.dev/install.{sh,ps1}`. That domain's Worker proxies both
files from Gitea raw `main`, so the published install command survives any
future forge move without being reprinted everywhere. install.ps1 also drops
the GitHub-specific `Accept: application/vnd.github+json`.

Everything else user-facing: `/feedback` and the shell's feedback fallback
point at the Gitea issue tracker, `/docs` at the Gitea-rendered README, and
web_fetch's User-Agent at kigicli.dev. SECURITY.md now routes reports to the
issue tracker with a warning to withhold exploitable detail, because Gitea
has no equivalent of GitHub's private vulnerability reporting. Comments
naming `.github/workflows/release.yml` name `.gitea/` now.

Tests assert against these constants rather than literal URLs, so they
follow automatically; the one URL-shape assertion (production endpoints are
https) still holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:13:19 -04:00
ZacharyZhang-NYandClaude Opus 5 8bdfb551b6 chore(git): ignore prompt.txt and the kigicli.dev worker artifacts
`/prompt.txt` is the one-shot Mac Mini runner-setup prompt. It carries a
Gitea runner registration token once filled in, and a leaked registration
token lets anyone attach a runner to the account and receive our jobs —
including release jobs. Anchored with a leading slash so a legitimately
tracked prompt.txt deeper in the tree would still be versioned.

The .wrangler/ and node_modules/ lines were already sitting uncommitted in
the working tree for the untracked frontend/ worker; they belong in this
same file, so they ride along rather than being split out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:24:30 -04:00
ZacharyZhang-NYandClaude Opus 5 8864b9ecc5 ci(gitea): add the release workflow and point it at our real runners
Gitea has no hosted runners, so every `runs-on` has to name a machine we
registered ourselves — and the labels in the first draft of this file did
not match what our machines advertise. The only online Linux runner
(`dokploy-runner-01`) offers `ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04`,
never `linux-x86_64`, and Gitea's API cannot relabel a runner remotely, so
a `v*` tag would have queued forever against a label nothing served.

The three Linux jobs therefore run on `ubuntu-latest`. That runner is in
container mode against a bare runner-image, so those legs now provision
what they need per run: build-essential, pkg-config, unzip (protoc), tar
and jq, plus rustup when absent — installed with `--default-toolchain none`
so `rust-toolchain.toml` still decides the channel and `rustup show`
materialises it. `prepare` installs jq/curl the same way. macOS stays on
`macos-arm64` and Windows on `windows-x86_64`, both host mode with their
toolchains preinstalled, so the new provisioning step is gated on a
`linux:` matrix key rather than on `ext == 'tar.gz'`, which covers Darwin
too.

docs/RELEASE.md picks up the corrected label table, the fact that
`register --labels` is ignored once config.yaml supplies them, the
registration-token call being a POST (a GET parses as runner id 0 and
404s), and how each host-mode runner is supervised so it survives reboots
and crashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:21:22 -04:00
ZacharyZhang-NY 553ec1c620 chore(release): bump workspace version to 0.1.11
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 5s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
All 62 crates inherit `workspace.package.version`, so the single edit
plus the refreshed lockfile carries the release. The `v0.1.11` tag is
what triggers the build; release.yml gates the tag against this value.

Ships the sessionless-model-switch fix (f656627): `/model` from a
non-project launch directory now starts the session its deferred switch
drains into instead of dangling silently.
2026-07-27 16:04:51 -04:00
ZacharyZhang-NY f656627c10 fix(tui): start the session a sessionless model switch defers into
Launching kigi in a non-project directory (~/Downloads, ~, /tmp — anywhere
`is_project_dir` rejects) leaves the agent view session-less behind the
project-picker question, which only a PLAIN prompt can open. Slash
commands still execute there, so `/model <name> <effort>` stashed its
switch in `deferred_model_switch` — a stash that assumes a create is in
flight — and with none pending it dangled forever with zero feedback:
the picker rendered, the user chose model and effort, and nothing
changed. Not a 0.1.10 regression: 0.1.9 pty-reproduces identically; the
report correlated with the upgrade only because every earlier launch
happened to be from a project directory.

Apply the QueueCommand precedent ("queued slash work bypasses the
picker, so create the deferred session or it never drains") to both
deferral sites — Action::SwitchModel in the router and
set_default_model's no-session branch — via
skip_picker_and_create_session, whose in-flight guard already makes it a
no-op while a create is pending, so the racing-create case is unchanged.
SessionCreated then applies the stash through the existing
apply_deferred_model_switch path; pty-verified end-to-end from
~/Downloads (session created, model changed, effort applied).
2026-07-27 15:55:57 -04:00
ZacharyZhang-NY 11c3ca9803 chore(release): bump workspace version to 0.1.10
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
All 62 crates inherit `workspace.package.version`, so the single edit
plus the refreshed lockfile carries the release. The `v0.1.10` tag is
what triggers the build; release.yml gates the tag against this value.

Also records the shared-SSRF-policy invariant in AGENTS.md, so a future
port does not reintroduce a second copy. The zero-egress entry is
compressed by the same number of lines to keep the file at 500.
2026-07-27 13:26:25 -04:00
ZacharyZhang-NY bac8470c80 fix(hooks): share one SSRF policy instead of a second, stale copy
The hook runner carried its own `is_blocked_ip`, a line-for-line copy of
the pre-hardening web_fetch predicate: loopback allowed unconditionally,
and no TEST-NET, 198.18/15, 240/4, 0.0.0.0/8, multicast, IPv6 site-local
or embedded-v4 wrapper coverage. Hook URLs come from settings, and
project settings from an untrusted repo are loaded today, so the gap is
reachable.

`kigi-hooks` already depends on `kigi-tools`, so the fix is to delete the
copy and call the shared predicate — no new crate, and no third
implementation to drift.

`allow_local` is on for hooks: a loopback hook receiver is a legitimate
local setup, which the old copy also allowed. It is now allowed only
when the URL names the host literally, so a public name resolving to
loopback is refused.

Range coverage now lives with the policy; the runner's tests pin what it
adds on top. The URL-scrubbing regression test moves off TEST-NET-1,
which the policy now blocks, onto a closed loopback port — faster, and
no real egress from a test.
2026-07-27 13:01:50 -04:00
ZacharyZhang-NY 74402f3078 fix(pager): stop a sibling test flattening the diff-band assertion
`committed_edit_keeps_diff_line_backgrounds` passed alone and
single-threaded but failed under parallel execution, so the workspace
gate was red.

`terminal_native_lock_paints_only_native_colors` engages the
process-global terminal-native lock. While it is held `Theme::current()`
returns `terminal_default()`, whose `diff_*_bg` are all `Color::Reset`,
so no band is painted at all — and `current_kind()` reports a nominal
`KigiNight`, which is what made the failure read as a theme mismatch.

Two changes, both at the cause:
- hold the shared theme lock via `theme_cache::pin_theme()`, the helper
  written for this and until now unused, so the lock cannot be engaged
  mid-test
- assert the invariant (two distinct non-Reset bands) instead of exact
  RGB: the entry line cache is keyed on the GLOBAL theme kind, so an
  exact-color assertion races by construction. Span-level colors stay
  pinned by the `tool::edit` tests that own them

The failure message now reports the theme, height, and the bands it
actually found, which is how the cause was located.
2026-07-27 12:38:14 -04:00
ZacharyZhang-NY 649c5f5641 fix(models): drop the ultra reasoning effort, which no backend accepts
The hardcoded Codex catalog advertised `ultra` for gpt-5.6-sol and
gpt-5.6-terra, so selecting it sent `reasoning.effort = "ultra"` and the
Responses endpoint answered 400 with its real menu, which ends at `max`.

The tier was kigi's invention: the enum documented it as codex-only, the
catalog was its only producer, and the upstream model snapshot never
listed it. Removing the variant as well as the catalog entries means
`/effort ultra` is rejected at the TUI instead of parsing and failing on
the wire; a persisted `ultra` degrades to the model default through
`lenient_reasoning_effort_opt`, as designed for vocabulary changes.
2026-07-27 12:12:14 -04:00
ZacharyZhang-NY ad3840f9ec fix(web_fetch): block non-public targets by default and gate every hop
kigi allowed loopback unconditionally and missed several non-public
ranges, and the SSRF check ran only on the initial URL.

Policy (ssrf.rs):
- loopback is blocked unless `[toolset.web_fetch] allow_local` (or
  KIGI_WEB_FETCH_ALLOW_LOCAL) is on, AND the URL names it explicitly,
  so a public name resolving to loopback stays blocked (DNS rebinding)
- add 0.0.0.0/8, 100.64/10, 192.0.0.0/24, TEST-NET-1/2/3, 198.18/15,
  240/4, IPv6 site-local and documentation prefixes
- inherit the IPv4 verdict through mapped, compatible, NAT64 and 6to4
  wrappers; network-specific NAT64 prefixes remain uncovered (see doc)

Plumbing (client.rs), where the exploitable half lived:
- re-check every redirect hop, not just the first
- compare hosts exactly; a `www` sibling has its own A records, so it
  is a cross-host redirect rather than an auto-followed hop
- run the check before the fetch service, so a blocked URL is never
  posted to an endpoint that egresses elsewhere
- exempt explicit local hosts from the https upgrade and from the
  single-label filter, and re-upgrade each followed hop

Wiring: allow_local reaches WebFetchParams from both construction
paths; documented in the config guide and the README env table.
2026-07-27 11:44:20 -04:00
ZacharyZhang-NY ac23ebc9a1 feat(turn): halt a turn stuck repeating one identical tool call
Ports grok-build's action stationarity breaker. A polling loop looks like
progress from inside the turn — every step succeeds — so nothing stops it
and it burns the whole budget re-reading one file. Nudge at 8 identical
batches, halt at 16, halt a pure no-op run at 4.

Three defects were found and fixed before this landed, none of which the
gates caught:

- The check runs AFTER execute_tool_calls, not before. Before it, the
  assistant's tool_calls are recorded with no results, so pushing the
  nudge triggers repair_dangling_tool_calls: synthetic "cancelled by the
  user" results get spliced in, the real results land after a user
  message, and dedup misses them — the orphan tool_result shape that
  draws a provider 400. It is Ok-gated too, since one `?` inside can
  leave a call resultless.
- `TurnOutcome::StationarityHalted` is its own variant grouped with
  Completed, not a reuse of Cancelled. As a cancellation it would report
  StopReason::Cancelled, fire the abort lifecycle, kill the turn's
  subagents, grow the goal harness back-off streak until three halts
  paused a running goal, and let completion-requirement recovery re-run
  the very turn halted for looping.
- The no-op gate is size-based, not name-based. kigi registers the shell
  tool as `run_terminal_cmd` and renames it `run_terminal_command`, so
  matching on "bash" silently disabled the tighter ceiling while every
  test still passed.
2026-07-27 10:37:41 -04:00
ZacharyZhang-NY a07d889ad9 fix(swarm): label each member with its own item
Every member was launched with the swarm's description, so the pager drew
N identical subagent blocks: the user could not tell which member was
running, and a failure could not be attributed to an item.

This is also why K6 does not port upstream's progress grid. A member IS
an ordinary subagent, so the pager already renders one block per member
with live status, duration, tool-call and turn counts — richer than a
grid cell. The gap was the label, not the widget; upstream's grid (and
the estimator behind it, which infers progress from tool-call rate)
answers a different TUI's shortcomings. The parent tool call already
reads "<description> (N members)" and `is_task_variant` already knows
`AgentSwarm`, so the blocking-wait spinner registers.
2026-07-27 03:01:42 -04:00
ZacharyZhang-NY 16328e55f9 feat(swarm): /swarm mode — a standing instruction to fan work out
The `agent_swarm` tool works with the mode off; what the mode adds is the
doctrine — decompose finely, give every member a disjoint scope, do not
do the work yourself. `/swarm`, `/swarm on|off`, `/swarm <task>`, gated
on the tool actually being in the toolset.

Two triggers, not upstream's three. Upstream's `tool` trigger exists so
invoking the tool makes the doctrine appear; kigi's tool carries its own
description and works with the mode off, so that trigger would add a
state only the tool can reach. `Manual` persists until switched off,
`Task` expires at turn end — both are real user intents.

Load-bearing decisions, each from a defect found in review:

- Expiry is a DROP guard, not a post-loop call. A user interrupt aborts
  the turn future rather than cancelling it, so post-loop code never
  runs; three `?` paths skip it too. Either way a `/swarm <task>` mode
  would leak into the next, unrelated prompt.
- `enter` is a total no-op while armed, so the `/swarm <task>` shorthand
  cannot downgrade a standing `/swarm on` into a per-turn mode that then
  disarms itself. This matches upstream; the first cut diverged, and the
  test asserting the divergence was inverted with the fix.
- An explicit `/swarm off` retracts UNCONDITIONALLY. The doctrine rides
  the conversation and so survives a resume, a fork and a compaction that
  an in-memory flag does not; trusting the flag left a session reading
  "off" with the instruction still steering and no way to clear it. That
  also retires the `reminder_live` field, whose doc comment claimed to
  know something the code cannot.
- The mode is deliberately NOT persisted, unlike /goal and /graph: those
  strand real work when lost, this is a prompt hint whose recovery is
  retyping one command. Recorded in AGENTS.md so the omission reads as a
  decision, not a gap.
- The pre-session gate is `subagents_enabled`, not a hardcoded `true`:
  the builder strips `agent_swarm` wherever it strips `task`, and
  advertising it then offers a menu entry that resolves to literal text.
- The exit reminder is as emphatic as the doctrine it revokes; a single
  weak clause is the likelier of the two to be summarised away.

The doctrine rides the existing `push_system_reminder` channel rather
than a second injection path of its own.
2026-07-27 02:39:55 -04:00
ZacharyZhang-NY 9edb8729ef feat(swarm): agent_swarm — one prompt over many items, paced as a fleet
Ports kimi-code's AgentSwarm: a `prompt_template` containing `{{item}}`
expanded over an `items` list into up to 128 subagents, run to completion
and returned as one aggregate. Kigi already exceeds upstream on planning,
verification, isolation and merge via /graph; what it lacked was cheap
immediate fan-out. Entirely client-side — no new backend surface.

The engine is three pure pieces plus a runner: `plan` validates and
expands (every fault reported before a single member starts — a
half-launched swarm is expensive to unwind), `schedule` is the launch
ramp as testable arithmetic, `run` drives it against the existing
`SubagentBackend`. Reuses the single-spawn coordinator rather than
inventing a batch API.

Load-bearing decisions, each the result of a defect found in review:

- `backgrounded` is its own outcome. A member that outlives the 600s
  foreground budget is detached by the coordinator and KEEPS RUNNING;
  reporting it as failed invites the model to relaunch its item, putting
  a second agent on the same files. It is never offered for resume.
- `InFlightGuard` cancels live members on Drop. Send-now cancels the turn
  WITHOUT cancelling subagents and aborts the task; the dropped receivers
  read as "parent gone" and each child re-attaches itself. There is no
  cooperative path to use instead — `Cancellation` is constructed nowhere
  in the tree — so Drop is the only seam that fires.
- Retries and wall clock are both bounded. The swarm blocks the caller's
  turn, so every wait needs a ceiling it cannot argue past; stragglers at
  the deadline are reported as still-running, with their ids.
- `ToolKind::AgentSwarm` is its own variant: `TemplateRenderer`'s
  `by_kind` map holds one tool name per kind, so sharing `Task` would
  silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
- An explicitly requested model that cannot be validated is refused, as
  the task tool already does — one loud error beats `items.len()` quiet
  ones. Depth stays capped at 1: upstream's unlimited nesting is a
  hazard, not a feature.
- `SubagentResult.rate_limited` is classified where the typed ACP error
  code is still in hand; a scheduler re-deriving it from a formatted
  string would stop adapting the day the wording changed.
- Aggregate output is clamped per member (head+tail, loss stated):
  native tool output is truncated nowhere downstream.

42 agent_swarm tests. The fake backend awaits, so the concurrency and
ordering assertions can actually fail; the cap test also proves the
fixture can exceed the cap.
2026-07-27 01:22:52 -04:00
ZacharyZhang-NY ed8049cf77 fix(memory): scope embedding credentials to the endpoint that may receive them
`MemoryBackendParams` carried the primary session `AuthManager` and the
session api-key provider unconditionally, while `embed_base_url` is the
CURRENT MODEL's endpoint and `AuthRetryMiddleware` stamps `Authorization`
on every request it wraps. A user who enables `[memory.embedding] model`
while running a BYOK or subscription-OAuth model therefore sent the Kimi
session bearer to that third party.

`EndpointScopedCredentials` binds the credential to the one endpoint it
may reach: `for_endpoint` drops the handle unless the caller vouches for
the URL, and `approved_for` re-checks at provider-build time in release
too, because `MemoryBackendParams` is `Clone` and callers rewrite fields
on the copy.

The shell decides through `CredentialAuthority::manager_for` rather than
a second URL predicate — it answers both whether a credential may ride
and which manager governs it, so a subscription-OAuth platform gets its
own pooled manager. The session's `SharedApiKeyProvider` is not
forwarded at all: it is hard-wired to the primary manager, so at a
pooled platform's host it would resolve the wrong bearer. A platform's
own `embed_api_key` is untouched and keeps serving its own endpoint.

The background reindex built a second provider straight from
`ApiEmbeddingProvider::from_session`, outside the chokepoint and without
401 refresh; it now embeds through the session's own params.

Test strength verified by mutation: with the guard reverted, exactly
`session_credentials_are_withheld_from_a_foreign_endpoint` and
`a_cloned_param_set_cannot_redirect_scoped_credentials` fail.
2026-07-26 23:41:45 -04:00
ZacharyZhang-NY 867b3e110b docs(comments): trim paste-fix commentary to the crucial constraints 2026-07-24 13:09:35 -04:00
ZacharyZhang-NY 7380576e50 release: v0.1.9
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
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
2026-07-24 12:46:25 -04:00
ZacharyZhang-NY 7ade135e76 fix(tui): reassemble Windows key-burst pastes so repaste-to-expand works
On Windows, crossterm's console backend never emits Event::Paste: a
terminal paste arrives as a per-character key burst that the event loop
reassembles with timing heuristics. Three of its bounds split real
pastes into fragments:

- the 10 ms continue window is below one Windows scheduler quantum
  (~15.6 ms), so a routine mid-burst gap ended collection early;
- the 5000-event safety cap truncated any paste past ~5 KB;
- the 2 ms detection window missed bursts whose second chunk lagged,
  so collection never started.

Each fragment became its own synthetic paste. The paste chip then held
only the first fragment, and pasting again could never byte-equal the
chip content, so repaste-to-expand inserted a duplicate instead of
expanding — while macOS worked, because the unix parser buffers a
bracketed paste to the end marker and always delivers one whole event.

Widen the continue window past one quantum (25 ms), raise the cap above
any real paste (200k events), and skip the detection window entirely
when the batch already holds a >=8-key burst, which typing and
auto-repeat never produce. Unix paths are untouched: batches containing
a real Event::Paste never enter this reassembly.

Covered by a paused-clock test that trickles a burst tail with
one-quantum gaps and asserts it reassembles into a single paste.
2026-07-24 12:41:26 -04:00
ZacharyZhang-NY f5b9000c5d fix(cgroup): stop swallowing inotify read errors in the memory monitor
wait_and_drain treated every read() <= 0 as "queue drained", so a real
error (bad fd, EINVAL) silently ended the drain and the monitor kept
polling a broken fd. Distinguish the cases: EAGAIN ends the drain, EINTR
retries, and any other error propagates. monitor_loop logs the error
before exiting so a dead memory monitor is visible in the session log.
2026-07-24 11:30:20 -04:00
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00
1584 changed files with 32765 additions and 28128 deletions

No files matched your search

+7
View File
@@ -1,3 +1,10 @@
/target
**/*.rs.bk
.DS_Store
# frontend/ (kigicli.dev worker)
.wrangler/
node_modules/
# Runner-setup prompt; holds a Gitea registration token once filled in.
/prompt.txt
+113 -14
View File
@@ -14,16 +14,50 @@ official `kimi` CLI: binary `kigi`, config dir `~/.kigi`
Never read or write `~/.kimi` (except the explicit one-time read-only
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, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
cycle, and through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle.
Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
start any future sync from those two, not from raw diffs.
Known limitations left by the 0.1.15 sync (fix before relying on them):
the Windows frame writer writes through `std::io::stderr`, so a console
that stops reading still blocks exit-path prints behind std's stderr lock
(unix silences fd 2 after a timed-out writer join); the bwrap command
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
a stat error that the profile classification treated as present.
Known limitation left by the 0.1.16 sync: `notifications/cancelled` for a
timed-out MCP call cannot reach a streamable-HTTP server that answers
`tools/call` with a plain JSON body. rmcp 2.2.0's HTTP worker awaits each
POST in turn, so the cancel queues behind the hung call;
`servers/cancel_aware.rs` waits 500 ms, warns, and the transport reset
then drops the connection. stdio and SSE-mode servers get the cancel.
The cure is rmcp 3.x (upstream runs 3.2), a major bump not yet taken.
Evaluated in the 0.1.16 window and left for a later cycle: MCP qualified
tool names past 64 chars (first prove the name never reaches a provider
function list), the auto-update guard for a binary still in use (needs
per-OS process scans), the DA1 fence around the kitty keyboard pop (needs
a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
(rmcp 3.x again), and `pager.toml`, whose writer in
`kigi-pager-render/src/appearance/config.rs` still ends in a bare
`fs::rename` and replaces a symlink.
## Hard constraints
- **Zero egress**: outbound connections are limited to
`auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`, `api.moonshot.ai`,
GitHub Releases domains, user-configured MCP servers, the endpoints of
provider platforms the user has credentialed, and `models.dev` (model
metadata refresh — reached ONLY when an enabled platform's `/models` wire
lacks metadata, `wire_serves_metadata=false`; Kimi/Moonshot never trigger
it; `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics, ever.
`crates/codegen/kigi-env` is the single home of first-party endpoints.
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
`api.moonshot.ai`, GitHub Releases, user-configured MCP servers, credentialed
provider endpoints, and `models.dev` (metadata refresh, ONLY when an enabled
platform's `/models` lacks it — `wire_serves_metadata=false`, never
Kimi/Moonshot, `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics,
ever. `crates/codegen/kigi-env` is the single home of first-party endpoints.
- **One SSRF policy**: `ssrf::is_blocked_for_host` gates every model/settings
URL (`web_fetch` + hooks), per redirect hop. Never write a second copy.
- **Toolchain**: Rust 1.97.0 (rust-toolchain.toml), edition 2024.
- **Gates** (all must stay green):
`cargo check --workspace --all-targets`,
@@ -40,6 +74,15 @@ import) or any `KIMI_*` env var.
switch that never stuck). Plain rename stays correct only for true moves
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
backups). Write failures must at least `warn!` — never `let _ =`.
- **config.toml writers resolve their destination through
`util::config::config_write_dest` before they name the tmp file.** A
symlinked `~/.kigi/config.toml` (dotfiles) keeps its link: the tmp sits
beside the referent and replaces it. Only that one path is followed, and
only while `user_kigi_home()` resolves (with no home, `kigi_home()` is the
project's own `./.kigi`). A project `.kigi/config.toml` is
repo-controlled, and following its symlink would overwrite whatever file
a hostile repo points it at; there the link itself is replaced.
Credential and managed stores never follow symlinks.
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
this repo). Members sorted; versions inherited from
`workspace.package.version` — the single source of truth for the release
@@ -164,6 +207,63 @@ edges stay deterministic Rust. The harness appends a terminal
the replan cap; `{"ops": []}` is a respected free no-op; failures
degrade.
## Swarm (`agent_swarm` tool + `/swarm` mode)
Cheap, unstructured fan-out — the one thing `/graph` does not offer. One
`prompt_template` containing `{{item}}` is expanded over an `items` list
into up to `MAX_AGENT_SWARM_MEMBERS` (128) ordinary subagents, run to
completion, and returned as ONE aggregate. Ported from kimi-code; entirely
client-side, no backend surface.
- Engine (`kigi-tools/.../kigi/agent_swarm/`): `plan.rs` validates and
expands (every fault is reported BEFORE a member starts; expanded
prompts must be pairwise distinct), `schedule.rs` is the launch ramp as
pure arithmetic (5 immediate, then 1 per 700ms; capacity shrinks on a
provider rate limit and recovers after a quiet window;
`KIGI_AGENT_SWARM_MAX_CONCURRENCY` caps it and a malformed value is a
hard error), `run.rs` drives it against the EXISTING single-spawn
`SubagentBackend`, `tool.rs` is the tool.
- `SwarmMemberOutcome::Backgrounded` is load-bearing: a member that
outlives the 600s foreground budget is detached by the coordinator and
KEEPS RUNNING. It is never reported `Failed` and never offered for
resume — relaunching its item would put a second agent on the same
files. Members share the caller's tree with NO isolation; the
distinct-prompt rule is the only thing keeping them apart.
- `InFlightGuard` (run.rs) cancels live members when the runner's future
is dropped. Send-now cancels the turn WITHOUT cancelling subagents and
aborts the task; the dropped receivers read as "parent gone" and each
child re-attaches itself. There is no cooperative path to use instead —
`kigi_tool_runtime::Cancellation` is constructed nowhere.
- Both retries (`MAX_RATE_LIMIT_RETRIES`) and wall clock
(`MAX_SWARM_RUNTIME`) are bounded: the swarm blocks the caller's turn,
so every wait needs a ceiling. Stragglers at the deadline are reported
as still-running WITH their ids.
- Underneath every fan-out (swarm, graph, raw Task) sits the
session-scoped spawn bound: `handle_subagent_request` holds one permit
from the parent session's semaphore for the whole child run
(kigi-tools `task/admission`: 32 default via
`KIGI_MAX_CONCURRENT_SUBAGENTS`, queue|fail via
`KIGI_SUBAGENT_LIMIT_BEHAVIOR`), so wide fan-outs queue instead of
exhausting file descriptors.
- `ToolKind::AgentSwarm` is its own variant because `TemplateRenderer`'s
`by_kind` map holds ONE tool name per kind — sharing `Task` would
silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
It gates exactly as `Task` does (`capability.rs`), and `builder.rs`
strips it wherever `task` is stripped. `MAX_SUBAGENT_DEPTH` stays 1:
upstream's unlimited nesting is a hazard, not a feature.
- `/swarm | /swarm on|off | /swarm <task>` (`BuiltinGate::Swarm` = the
tool is in the toolset) arms a doctrine reminder via the existing
`push_system_reminder`. Two triggers only: `Manual` persists until
switched off, `Task` auto-expires at turn end via `SwarmTurnGuard` — a
DROP guard, because a user interrupt aborts the turn future and never
reaches post-loop code. `enter` is a total no-op while armed so the
`/swarm <task>` shorthand cannot downgrade a standing `/swarm on`.
- The mode is deliberately NOT persisted (unlike `/goal` and `/graph`,
which strand real work if lost): it is a prompt hint whose recovery is
retyping one command. The injected doctrine IS durable, so an explicit
`/swarm off` retracts UNCONDITIONALLY — a resumed or compacted session
can read as "off" with the instruction still in context.
## Provider registry & API-key auth (post-0.1.3 expansion)
- The platform registry is compiled-in spec rows in `kigi-models`
@@ -282,8 +382,7 @@ edges stay deterministic Rust. The harness appends a terminal
reasoning replay requires `include:["reasoning.encrypted_content"]`.
API-key `openai` Responses requests carry NONE of this
(byte-identical, pinned by a control wire test). `reasoning.effort`
carries the thinking level (incl. the codex-only `ultra`). NO
websocket, NO base_instructions.
carries the thinking level. NO websocket, NO base_instructions.
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
`openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
@@ -432,10 +531,10 @@ edges stay deterministic Rust. The harness appends a terminal
`parse_api_json` for bundled + runtime refresh; 24h cache
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
never invents model availability. Canonical reasoning efforts:
none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's
server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat
renames).
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
`ultra` was dropped the same month, no backend accepts it; Kimi wire
spells its top tier `max`, kimi_compat renames). config.toml accepts any
letter case: `ReasoningEffort` deserializes through `FromStr`.
## Milestones (PRD §8.3)
Generated
+96 -93
View File
@@ -228,7 +228,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -251,7 +251,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -720,9 +720,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.1"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -731,9 +731,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.42.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
@@ -1626,9 +1626,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -1810,7 +1810,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -2654,7 +2654,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2980,7 +2980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3958,7 +3958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.15.5",
]
[[package]]
@@ -4497,9 +4497,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.8"
version = "0.1.16"
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.8"
version = "0.1.16"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"chrono",
@@ -6186,13 +6187,14 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"tempfile",
"toml",
"tracing",
]
[[package]]
name = "kigi-secrets"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.8"
version = "0.1.16"
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.8"
version = "0.1.16"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.8"
version = "0.1.16"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.8"
version = "0.1.16"
[[package]]
name = "kigi-tty-utils"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"base64",
"chrono",
@@ -7305,7 +7308,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7576,7 +7579,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7997,7 +8000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -8840,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.8"
version = "0.1.16"
dependencies = [
"anyhow",
"axum",
@@ -8992,7 +8995,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9617,7 +9620,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9633,9 +9636,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -9687,7 +9690,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9698,9 +9701,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -10536,7 +10539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10937,7 +10940,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11882,7 +11885,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -12414,15 +12417,15 @@ dependencies = [
[[package]]
name = "webbrowser"
version = "1.2.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"core-foundation 0.10.1",
"jni",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"url",
"web-sys",
@@ -12561,7 +12564,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.8"
version = "0.1.16"
edition = "2024"
license = "Apache-2.0"
+4 -4
View File
@@ -36,12 +36,12 @@ key? OpenAI, Anthropic, Google, DeepSeek, Groq, Moonshot and
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | bash
curl -fsSL https://kigicli.dev/install.sh | bash
```
```powershell
# Windows PowerShell
irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1 | iex
irm https://kigicli.dev/install.ps1 | iex
```
```sh
@@ -176,8 +176,8 @@ keyring entry, and its own `KIGI_*` env vars, and never touches what the
official `kimi` CLI installed. `kigi import-kimi` copies your old config over
once, read-only.
**Zero telemetry.** It talks to the APIs you configured, GitHub Releases, and
your own MCP servers. Nothing else.
**Zero telemetry.** It talks to the APIs you configured, the Kigi release
server, and your own MCP servers. Nothing else.
## License
+5 -3
View File
@@ -1,8 +1,10 @@
# Security Policy
Kigi is a community build; report vulnerabilities via
[GitHub private vulnerability reporting](https://github.com/ZacharyZhang-NY/Kigi-CLI/security/advisories/new)
on this repository. Please do not open public issues for security reports.
Kigi is a community build; report vulnerabilities on the
[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.
Dependency advisories are gated in CI with `cargo deny check advisories`;
documented exceptions live in `deny.toml`.
+5936 -4507
View File
File diff suppressed because it is too large. Load diff
@@ -24,22 +24,14 @@ fn is_github_actions() -> bool {
env::var_os("GITHUB_ACTIONS").is_some()
}
/// Find `protoc` command.
/// Locate `protoc`.
///
/// Search order:
/// 1. `$PROTOC` environment variable (set by Bazel `build_script_env` or user override)
/// 2. `bin/protoc` walking up parent directories (dotslash wrapper for local dev)
/// 3. `protoc` on `$PATH` (system install or other tooling)
///
/// When `bin/protoc` exists but fails to execute (e.g. the dotslash wrapper running
/// in Bazel remote execution where `dotslash` is not installed), the error is not fatal —
/// we fall through to the PATH-based lookup instead.
///
/// Returns `Ok(None)` if not found and not in a strict environment (GitHub Actions).
/// Search order: `$PROTOC`, then `bin/protoc` walking parents (dotslash
/// wrapper), then `$PATH`. A non-executable `bin/protoc` (e.g. dotslash
/// missing under Bazel remote execution) is non-fatal — lookup continues
/// on `$PATH`. Returns `Ok(None)` when missing outside GitHub Actions.
pub fn find_protoc() -> anyhow::Result<Option<PathBuf>> {
// 1. Check the PROTOC env var first. This is the standard override used by prost-build
// and is set by Bazel cargo_build_script build_script_env to point at a hermetic
// protoc binary instead of the dotslash wrapper.
// `$PROTOC` is the prost-build override; Bazel sets it to a hermetic binary.
if let Ok(protoc_env) = env::var("PROTOC") {
let protoc = PathBuf::from(&protoc_env);
if protoc.try_exists()? {
@@ -48,20 +40,17 @@ pub fn find_protoc() -> anyhow::Result<Option<PathBuf>> {
}
}
// 2. Walk up directories looking for bin/protoc (dotslash wrapper).
let cwd = env::current_dir()?;
let mut dir = cwd.clone();
let mut dir_rel = PathBuf::new();
loop {
// Return relative path to make build more deterministic.
// Relative path keeps cargo rerun fingerprints stable across machines.
let protoc = dir_rel.join("bin/protoc");
if protoc.try_exists()? {
match check_protoc_good(&protoc) {
Ok(()) => return Ok(Some(protoc)),
Err(e) => {
// bin/protoc exists but can't execute — likely the dotslash wrapper
// in an environment without dotslash (e.g. Bazel remote execution).
// Fall through to PATH-based lookup below.
// Dotslash wrapper present but not runnable — try PATH next.
eprintln!(
"bin/protoc found at `{}` but failed to execute: {e:#}; \
trying protoc from PATH as fallback",
@@ -77,12 +66,10 @@ pub fn find_protoc() -> anyhow::Result<Option<PathBuf>> {
dir_rel.push("..");
}
// 3. Try protoc from PATH (system install or other tooling).
if check_protoc_good(Path::new("protoc")).is_ok() {
return Ok(Some(PathBuf::from("protoc")));
}
// 4. Not found anywhere.
if is_github_actions() {
return Err(anyhow::anyhow!(
"`protoc` not found (checked $PROTOC env, bin/protoc, and PATH)"
+18 -40
View File
@@ -5,22 +5,16 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::{env, fs, iter};
/// Find the protoc well-known types include directory.
/// Resolve protoc's well-known-types include dir (`../include` next to `bin/protoc`).
///
/// When PROTOC is set (e.g., in Bazel), the include directory is typically
/// at `../include` relative to the `bin/protoc` binary. For example:
/// - PROTOC = `/path/to/external/protoc_linux_x86_64/bin/protoc`
/// - Include = `/path/to/external/protoc_linux_x86_64/include`
///
/// This is needed because Bazel places the protoc binary and include files
/// in separate locations within the sandbox, and protoc doesn't automatically
/// find them without an explicit -I flag.
/// Bazel keeps the binary and includes in separate sandbox paths; protoc will
/// not find them without an explicit `-I`.
fn find_protoc_include_dir(protoc: Option<&Path>) -> Option<PathBuf> {
let protoc = protoc?;
// protoc is typically at .../bin/protoc, so include is at .../include
let parent = protoc.parent()?; // .../bin
let grandparent = parent.parent()?; // .../
// Layout: `.../bin/protoc` → sibling `.../include`.
let parent = protoc.parent()?;
let grandparent = parent.parent()?;
let include_dir = grandparent.join("include");
if include_dir.is_dir() {
@@ -72,10 +66,8 @@ impl XaiProtoBuilder {
self
}
/// Serialize JSON using the original proto field names (snake_case) instead
/// of the proto3-JSON default (camelCase). Deserialization still accepts
/// both casings, so this is backward-compatible with already-stored
/// camelCase documents.
/// Emit JSON with original proto field names (snake_case) instead of
/// proto3-JSON camelCase. Deserialization still accepts both casings.
pub fn pbjson_preserve_proto_field_names(mut self) -> Self {
self.pbjson_preserve_proto_field_names = true;
self
@@ -93,10 +85,8 @@ impl XaiProtoBuilder {
self.map_builder(|b| b.field_attribute(path, attr))
}
// tonic-build generation of `rerun-if-changed` is lazy and incorrect.
// - everything is invalidated when anything inside include directories is changed
// - also they compute paths incorrectly: assuming paths are relative to current directory
// rather than
// tonic-build's `rerun-if-changed` is lazy and wrong: any include-dir
// touch invalidates everything, and paths are treated as CWD-relative.
fn emit_rerun_if_changed<'a>(
protoc: Option<&Path>,
protoc_include_dir: Option<&Path>,
@@ -112,11 +102,9 @@ impl XaiProtoBuilder {
);
}
// Can only process one input file when using --dependency_out=FILE.
// Both protoc outputs go to real files: /dev/stdout and /dev/null do
// not exist on Windows (the release build failed on exactly this).
// OUT_DIR is always set for build scripts; deterministic names make
// reruns overwrite instead of accumulate.
// `--dependency_out` accepts one input per invocation. Write real
// files (not /dev/stdout|/dev/null — missing on Windows). OUT_DIR
// names stay stable so reruns overwrite rather than accumulate.
let scratch_dir = env::var_os("OUT_DIR")
.map(PathBuf::from)
.unwrap_or_else(env::temp_dir);
@@ -135,9 +123,7 @@ impl XaiProtoBuilder {
descriptor_file.display()
));
// Add protoc's well-known types include directory first (if found).
// This is needed for Bazel sandboxed builds where protoc and its
// include files are in different locations.
// Well-known types first so Bazel sandboxes resolve them.
if let Some(include_dir) = protoc_include_dir {
command.arg(format!(
"-I{}",
@@ -162,9 +148,8 @@ impl XaiProtoBuilder {
let output = fs::read_to_string(&dep_file)
.with_context(|| format!("read protoc dependency file {}", dep_file.display()))?;
// Make-style `.d` format: `<descriptor path>: dep1 dep2 …`.
// Compare with normalized separators — protoc may spell the
// target path with forward slashes even on Windows.
// Make-style `.d`: `<descriptor path>: dep1 dep2 …`.
// Normalize separators — protoc may emit `/` even on Windows.
let mut lines = output.lines();
let first_line = lines.next().context("protoc dependency output is empty")?;
let normalized_first = first_line.replace('\\', "/");
@@ -179,9 +164,7 @@ impl XaiProtoBuilder {
for line in iter::once(rem).chain(lines) {
let line = line.trim();
let line = line.strip_suffix("\\").unwrap_or(line);
// Depending on absolute paths like
// /Users/user/homebrew/Cellar/protobuf/29.1/include/google/protobuf/timestamp.proto
// is valid, but we want to have output more deterministic.
// Skip host-absolute well-known includes so fingerprints stay portable.
if line.contains("/include/google/protobuf/") {
continue;
}
@@ -224,14 +207,10 @@ impl XaiProtoBuilder {
let protoc = find_protoc::find_protoc()?;
// Use fixed version of `protoc` binary.
if let Some(protoc) = &protoc {
config.protoc_executable(protoc);
}
// Find the protoc's well-known types include directory.
// This is needed for Bazel sandboxed builds where protoc and its
// include files are placed in different sandbox locations.
let protoc_include_dir = find_protoc_include_dir(protoc.as_deref());
let mut builder = builder.emit_rerun_if_changed(false);
@@ -256,8 +235,7 @@ impl XaiProtoBuilder {
None
};
// Build the full includes list, prepending the protoc include directory
// if found (for well-known types like google/protobuf/timestamp.proto).
// Prepend protoc includes so well-known types resolve under Bazel.
let all_includes: Vec<&Path> = protoc_include_dir
.as_deref()
.into_iter()
+2 -1
View File
@@ -78,7 +78,8 @@ mod acp_send_failure_tests {
#[tokio::test]
async fn send_failed_when_receiver_dropped_before_send() {
let (tx, rx) = mpsc::unbounded_channel::<AcpAgentMessage>();
drop(rx); // no peer listening -> enqueue fails
// no peer listening -> enqueue fails
drop(rx);
let err = acp_send(ext_request(), &tx).await.unwrap_err();
assert_eq!(
acp_channel_failure(&err),
+14 -15
View File
@@ -20,24 +20,24 @@ pub fn acp_internal_error(message: impl Into<String>) -> acp::Error {
/// The two distinct ways an [`acp_send`](crate::acp_send) round-trip can fail
/// when the underlying channel is closed. Both surface as a JSON-RPC
/// `INTERNAL_ERROR` (so existing callers and the wire format are unaffected);
/// this typed discriminant — carried in the error's `data` — lets callers tell
/// them apart WITHOUT substring-matching the human-readable `message`.
/// `INTERNAL_ERROR`; this typed discriminant — carried in the error's `data` —
/// lets callers tell them apart without substring-matching the human-readable
/// `message`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpChannelFailure {
/// The request could not be ENQUEUED: the receiver half (the peer's
/// The request could not be enqueued: the receiver half (the peer's
/// connection task) is already gone, so no peer is listening — e.g. a
/// headless run with no client wired.
SendFailed,
/// The request was enqueued but the RESPONSE channel was dropped before a
/// The request was enqueued but the response channel was dropped before a
/// reply arrived: a peer received the request, then went away (disconnect /
/// process exit) without answering.
RecvFailed,
}
impl AcpChannelFailure {
/// `data` object key under which [`acp_send`](crate::acp_send) records the
/// kind. Namespaced so it can never collide with other `with_data` payloads.
/// `data` object key under which the kind is recorded. Namespaced so it can
/// never collide with other `with_data` payloads.
const DATA_KEY: &'static str = "xaiAcpChannelFailure";
const fn tag(self) -> &'static str {
@@ -58,8 +58,8 @@ impl AcpChannelFailure {
/// Build the channel-closed error for [`acp_send`](crate::acp_send), tagging it
/// with a typed [`AcpChannelFailure`] discriminant in `data`. The error `code`
/// stays `INTERNAL_ERROR`, so this is purely additive for callers that just
/// propagate the error.
/// stays `INTERNAL_ERROR` so callers that merely propagate the error are
/// unaffected.
pub(crate) fn acp_channel_failure_error(
message: impl Into<String>,
kind: AcpChannelFailure,
@@ -68,8 +68,8 @@ pub(crate) fn acp_channel_failure_error(
}
/// Recover the [`AcpChannelFailure`] kind from an error, or `None` if the error
/// did not originate from [`acp_send`](crate::acp_send)'s channel-closed paths
/// (or predates the tag). Consumers use this instead of inspecting `message`.
/// did not originate from [`acp_send`](crate::acp_send)'s channel-closed paths.
/// Consumers use this instead of inspecting `message`.
pub fn acp_channel_failure(err: &acp::Error) -> Option<AcpChannelFailure> {
err.data
.as_ref()
@@ -78,10 +78,9 @@ pub fn acp_channel_failure(err: &acp::Error) -> Option<AcpChannelFailure> {
.and_then(AcpChannelFailure::from_tag)
}
/// Compact single-line JSON for gateway debug traces. Plain (uncolored)
/// output: this feeds `tracing::debug!`, which typically lands in log files
/// where ANSI colors are noise. Replaces the former `colored_json`-backed
/// `color_json` (dropped to shrink the shipped dependency tree).
/// Compact single-line JSON for gateway debug traces. Output is uncolored: it
/// feeds `tracing::debug!`, which typically lands in log files where ANSI
/// escapes are noise.
#[doc(hidden)]
pub fn compact_json<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
+4 -8
View File
@@ -613,7 +613,8 @@ mod tests {
})
.collect();
// Gate-open point; then concurrent producer emits live updates.
// Phase 2: a concurrent producer emits live updates while the
// replay completions are still draining.
let live_sender = sender.clone();
let producer = tokio::task::spawn_local(async move {
for i in 0..LIVE {
@@ -624,15 +625,14 @@ mod tests {
}
});
// Drain replay completions while producer runs.
for rx in completions {
let _ = rx.await;
}
// Mark response boundary.
log.borrow_mut().push("RESPONSE".into());
// Let producer and gateway finish remaining live updates.
// Give the producer and the gateway loop room to flush the
// remaining live updates before inspecting the log.
let _ = producer.await;
for _ in 0..LIVE + 5 {
tokio::task::yield_now().await;
@@ -644,7 +644,6 @@ mod tests {
.position(|s| s == "RESPONSE")
.expect("RESPONSE marker must be in the log");
// (1) Delta notifications are all present and before RESPONSE.
for i in 0..DELTA {
let tag = format!("delta-{i}");
let pos = log
@@ -657,7 +656,6 @@ mod tests {
);
}
// (2) Delta notifications preserve enqueue order.
let delta_positions: Vec<usize> = (0..DELTA)
.map(|i| log.iter().position(|s| s == &format!("delta-{i}")).unwrap())
.collect();
@@ -670,7 +668,6 @@ mod tests {
);
}
// (3) No live updates are lost.
for i in 0..LIVE {
let tag = format!("live-{i}");
assert!(
@@ -679,7 +676,6 @@ mod tests {
);
}
// (4) Live updates do not precede replay delta.
let last_delta = *delta_positions.last().unwrap();
for i in 0..LIVE {
let tag = format!("live-{i}");
@@ -126,7 +126,7 @@ impl AsyncRead for LineBufferedRead {
Poll::Ready(Ok(n))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Err(e)),
Poll::Ready(None) => Poll::Ready(Ok(0)), // EOF
Poll::Ready(None) => Poll::Ready(Ok(0)),
Poll::Pending => Poll::Pending,
}
}
@@ -146,7 +146,7 @@ async fn read_line_capped(
let (consumed, done) = {
let available = reader.fill_buf().await?;
if available.is_empty() {
return Ok(buf.len()); // EOF
return Ok(buf.len());
}
match available.iter().position(|&b| b == b'\n') {
Some(pos) => {
@@ -283,15 +283,12 @@ mod tests {
let mut reader = LineBufferedRead::spawn_local(source);
let mut small_buf = [0u8; 3];
// First read: "abc"
let n = reader.read(&mut small_buf).await.unwrap();
assert_eq!(&small_buf[..n], b"abc");
// Second read: "def"
let n = reader.read(&mut small_buf).await.unwrap();
assert_eq!(&small_buf[..n], b"def");
// Third read: "\n"
let n = reader.read(&mut small_buf).await.unwrap();
assert_eq!(&small_buf[..n], b"\n");
+12 -6
View File
@@ -26,16 +26,20 @@ pub trait AcpSide {
/// Marker type representing the agent's view of the ACP connection (as one side of that connection).
impl AcpSide for acp::AgentSide {
type InMessage = AcpAgentMessage; // inbound messages = messages meant *for* the agent
type OutMessage = AcpClientMessage; // outbound messages = messages meant *for* the client
// inbound messages = messages meant *for* the agent
type InMessage = AcpAgentMessage;
// outbound messages = messages meant *for* the client
type OutMessage = AcpClientMessage;
type OtherSide = acp::ClientSide;
const NAME: &'static str = "agent";
}
/// Marker type representing the agent's view of the ACP connection (as one side of that connection).
impl AcpSide for acp::ClientSide {
type InMessage = AcpClientMessage; // inbound messages = messages meant *for* the client
type OutMessage = AcpAgentMessage; // outbound messages = messages meant *for* the agent
// inbound messages = messages meant *for* the client
type InMessage = AcpClientMessage;
// outbound messages = messages meant *for* the agent
type OutMessage = AcpAgentMessage;
type OtherSide = acp::AgentSide;
const NAME: &'static str = "client";
}
@@ -241,7 +245,8 @@ mod client {
pub fn route_to_client(
self,
client: impl acp::Client + 'static, // note: acp::Client is auto-implemented for Rc/Arc
// note: acp::Client is auto-implemented for Rc/Arc
client: impl acp::Client + 'static,
spawn: impl Fn(LocalBoxFuture<'static, ()>) + 'static,
) {
match self {
@@ -540,7 +545,8 @@ mod agent {
pub fn route_to_agent(
self,
agent: impl acp::Agent + 'static, // note: acp::Agent is auto-implemented for Rc/Arc
// note: acp::Agent is auto-implemented for Rc/Arc
agent: impl acp::Agent + 'static,
spawn: impl Fn(LocalBoxFuture<'static, ()>) + 'static,
) {
match self {
+1 -1
View File
@@ -33,7 +33,7 @@
/// `\u2028` and surrogate pairs in text).
///
/// Any line that fails both parses passes through byte-identical —
/// deliberately: the acp crate keeps ownership of garbage handling.
/// Deliberately: the acp crate keeps ownership of garbage handling.
pub(crate) fn normalize_json_line(line: Vec<u8>) -> Vec<u8> {
if !line.windows(2).any(|w| w == br"\/") {
return line;
@@ -138,7 +138,8 @@ fn isolate_process_stdin() -> Option<std::fs::File> {
use std::os::windows::io::FromRawHandle as _;
// Win32 constants (inlined to avoid a dependency).
const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // (DWORD)-10
// (DWORD)-10
const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6;
const DUPLICATE_SAME_ACCESS: u32 = 0x0000_0002;
const GENERIC_READ: u32 = 0x8000_0000;
const FILE_SHARE_READ: u32 = 0x0000_0001;
@@ -186,7 +187,8 @@ fn isolate_process_stdin() -> Option<std::fs::File> {
process,
&mut duplicate,
0,
0, // not inheritable
// not inheritable
0,
DUPLICATE_SAME_ACCESS,
) == 0
{
@@ -4,8 +4,9 @@ use crate::send::contributors::command::{
CommandAction, CommandContributor, CommandInvocation, CommandSpec,
};
/// `?Send` twin of [`CommandContributor`] for single-threaded hosts like kigi build's TUI agent, whose session state is `Rc`/`RefCell`-based and can
/// never satisfy the `Send` bounds the send flavor bakes into its boxed hook futures.
/// `?Send` twin of [`CommandContributor`] for single-threaded hosts like kigi build's TUI agent,
/// whose session state is `Rc`/`RefCell`-based and can never satisfy the `Send` bounds the send
/// flavor bakes into its boxed hook futures.
#[async_trait(?Send)]
pub trait LocalCommandContributor {
fn advertised_commands(&self) -> Vec<CommandSpec>;
@@ -14,7 +15,8 @@ pub trait LocalCommandContributor {
-> Result<CommandAction, String>;
}
/// Send contributors work in single-threaded hosts as-is, so shared logic implements [`CommandContributor`] once and both hosts can register it.
/// Send contributors work in single-threaded hosts as-is, so shared logic implements
/// [`CommandContributor`] once and both hosts can register it.
#[async_trait(?Send)]
impl<T: CommandContributor> LocalCommandContributor for T {
fn advertised_commands(&self) -> Vec<CommandSpec> {
@@ -5,7 +5,8 @@ use crate::send::contributors::session_lifecycle::{SessionIdleInput, SessionLife
/// `?Send` twin of [`SessionLifecycleContributor`].
#[async_trait(?Send)]
pub trait LocalSessionLifecycleContributor {
/// Fired when the session settles idle (no running turn or queued work); the host owns the check.
/// Fired when the session settles idle (no running turn or queued work); the host owns the
/// check.
async fn on_session_idle(&self, _input: &SessionIdleInput) {}
}
@@ -4,8 +4,9 @@ use crate::send::contributors::turn_input::{
TurnInputContext, TurnInputContributor, TurnInputFragment,
};
/// `?Send` twin of [`TurnInputContributor`] for single-threaded hosts like kigi build's TUI agent, whose session state is `Rc`/`RefCell`-based
/// and can never satisfy the `Send` bounds the send flavor bakes into its boxed hook futures.
/// `?Send` twin of [`TurnInputContributor`] for single-threaded hosts like kigi build's TUI agent,
/// whose session state is `Rc`/`RefCell`-based and can never satisfy the `Send` bounds the send
/// flavor bakes into its boxed hook futures.
#[async_trait(?Send)]
pub trait LocalTurnInputContributor {
async fn contribute_turn_input(&self, _input: &TurnInputContext) -> Vec<TurnInputFragment> {
@@ -13,7 +14,8 @@ pub trait LocalTurnInputContributor {
}
}
/// Send contributors are usable in single-threaded hosts as-is, so shared logic implements [`TurnInputContributor`] once for both hosts.
/// Send contributors are usable in single-threaded hosts as-is, so shared logic implements
/// [`TurnInputContributor`] once for both hosts.
#[async_trait(?Send)]
impl<T: TurnInputContributor> LocalTurnInputContributor for T {
async fn contribute_turn_input(&self, input: &TurnInputContext) -> Vec<TurnInputFragment> {
@@ -6,7 +6,6 @@ use crate::local::contributors::{
LocalTurnLifecycleContributor,
};
/// Mutable registry used while hosts register typed runtime contributions.
#[derive(Default)]
pub struct LocalExtensionRegistryBuilder {
turn_lifecycle_contributors: Vec<Rc<dyn LocalTurnLifecycleContributor>>,
@@ -67,7 +66,6 @@ impl LocalExtensionRegistryBuilder {
}
}
/// Immutable typed registry produced after extensions are installed.
#[derive(Default)]
pub struct LocalExtensionRegistry {
turn_lifecycle_contributors: Vec<Rc<dyn LocalTurnLifecycleContributor>>,
@@ -94,7 +92,6 @@ impl LocalExtensionRegistry {
&self.command_contributors
}
/// The one contributor owning `name`, or `None` when no extension advertised it.
pub fn command_handler(&self, name: &str) -> Option<&Rc<dyn LocalCommandContributor>> {
self.command_handlers.get(name)
}
@@ -10,7 +10,8 @@ pub struct CommandSpec {
/// A parsed `/name args` invocation. The host owns parsing and routes it to the command's one owner.
pub struct CommandInvocation<'a> {
pub name: &'a str,
pub args: &'a str, // Whitespace-trimmed; empty for a bare `/name`.
// Whitespace-trimmed; empty for a bare `/name`.
pub args: &'a str,
}
/// What a handled command does to the turn; rejections travel as the `Err` reason.
@@ -1,10 +1,9 @@
use async_trait::async_trait;
/// Input supplied when the host observes the session settling idle.
pub struct SessionIdleInput;
#[async_trait]
pub trait SessionLifecycleContributor: Send + Sync {
/// Fired when the session settles idle (no running turn or queued work); the host owns the check.
/// Idle means no running turn and no queued work; the host owns that check.
async fn on_session_idle(&self, _input: &SessionIdleInput) {}
}
@@ -1,20 +1,17 @@
use async_trait::async_trait;
/// Turn facts supplied when the host pulls extension input at its sampling chokepoint.
pub struct TurnInputContext {
/// Stable host-owned turn identifier.
pub turn_id: String,
/// True when the harness produced the turn (auto-wake, drain, cron, continuation), not the user.
pub synthetic: bool,
}
/// A model-visible input fragment contributed into the active turn. The host owns wrapping, origin stamping, and placement.
/// Raw fragment text: the host owns wrapping, origin stamping, and placement.
pub struct TurnInputFragment {
pub text: String,
}
/// Contributes model-visible input fragments into the active turn when the host pulls at its sampling chokepoint.
/// Fragments land in the same turn, never a new one.
/// Fragments land in the turn the host is already sampling, never a new one.
#[async_trait]
pub trait TurnInputContributor: Send + Sync {
async fn contribute_turn_input(&self, _input: &TurnInputContext) -> Vec<TurnInputFragment> {
@@ -1,6 +1,5 @@
use async_trait::async_trait;
/// Input supplied when the host starts a turn.
pub struct TurnStartInput {
/// True when the harness produced the turn (auto-wake, drain, cron, continuation), not the user.
pub synthetic: bool,
@@ -12,19 +11,16 @@ impl TurnStartInput {
}
}
/// Input supplied when the host completes a turn.
pub struct TurnDoneInput;
/// Why the host aborted the turn instead of completing it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TurnAbortReason {
/// The client went away mid-turn.
Disconnected,
/// The user interrupted the turn before it completed.
/// The user cancelled mid-turn.
Interrupted,
}
/// Input supplied when the host aborts a turn.
pub struct TurnAbortInput {
pub reason: TurnAbortReason,
}
@@ -35,7 +31,6 @@ impl TurnAbortInput {
}
}
/// Input supplied when the host observes an error for a turn.
pub struct TurnErrorInput<'a> {
pub message: &'a str,
}
@@ -5,7 +5,6 @@ use crate::send::contributors::{
CommandContributor, SessionLifecycleContributor, TurnInputContributor, TurnLifecycleContributor,
};
/// Mutable registry used while hosts register typed runtime contributions.
#[derive(Default)]
pub struct ExtensionRegistryBuilder {
turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>,
@@ -38,8 +37,8 @@ impl ExtensionRegistryBuilder {
self.command_contributors.push(contributor);
}
/// Routes each advertised command to its one owner. Duplicate names are a composition bug:
/// first registration wins, panics in debug builds, logs in release.
/// Two extensions advertising one command name is a composition bug, so it trips a
/// `debug_assert`; release builds keep the first registration and log the loser.
pub fn build(self) -> ExtensionRegistry {
let mut command_handlers: HashMap<String, Arc<dyn CommandContributor>> = HashMap::new();
for contributor in &self.command_contributors {
@@ -63,7 +62,6 @@ impl ExtensionRegistryBuilder {
}
}
/// Immutable typed registry produced after extensions are installed.
#[derive(Default)]
pub struct ExtensionRegistry {
turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>,
@@ -90,7 +88,6 @@ impl ExtensionRegistry {
&self.command_contributors
}
/// The one contributor owning `name`, or `None` when no extension advertised it.
pub fn command_handler(&self, name: &str) -> Option<&Arc<dyn CommandContributor>> {
self.command_handlers.get(name)
}
+3 -7
View File
@@ -77,7 +77,7 @@ impl Agent {
}
}
// ── From definition ──────────────────────────────────────────────
// From definition
/// Agent name (unique identifier).
pub fn name(&self) -> &str {
@@ -99,14 +99,12 @@ impl Agent {
&self.definition.permission_mode
}
/// Completion requirement, if any.
pub fn completion_requirement(&self) -> Option<&CompletionRequirement> {
self.definition.completion_requirement.as_ref()
}
// ── Session-level ────────────────────────────────────────────────
// Session-level
/// The rendered system prompt.
pub fn system_prompt(&self) -> &str {
&self.system_prompt
}
@@ -123,12 +121,10 @@ impl Agent {
&self.tool_bridge
}
/// Compaction policy.
pub fn compaction_policy(&self) -> &CompactionPolicy {
&self.compaction_policy
}
/// Reminder policy.
pub fn reminder_policy(&self) -> &ReminderPolicy {
&self.reminder_policy
}
@@ -216,7 +212,7 @@ impl Agent {
/// Does NOT rebuild the tool registry or re-render prompts.
/// Used for mid-session mode switching.
pub async fn update_policies_from_definition(&self, _def: &AgentDefinition) {
// TODO: completion requirements and retry configs are now part of
// TODO: completion requirements and retry configs are part of
// ToolServerConfig and handled at registry finalization time.
// Mid-session policy updates are not yet supported in the new architecture.
}
+33 -4
View File
@@ -711,9 +711,19 @@ impl AgentBuilder {
kigi_tools::types::tool::ToolNamespace::Kigi,
"task"
);
// The swarm is a fan-out of subagent spawns, so it lives and dies with
// the task tool: any condition that leaves no subagent to spawn leaves
// the swarm with nothing to fan out to.
let swarm_tool_id = format!(
"{}:{}",
kigi_tools::types::tool::ToolNamespace::Kigi,
"agent_swarm"
);
let mut task_stripped = false;
if !self.subagents_enabled {
tool_config.tools.retain(|tc| tc.id != task_tool_id);
tool_config
.tools
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
task_stripped = true;
} else {
let subagents = crate::discovery::all_subagents_with_plugins(
@@ -722,7 +732,9 @@ impl AgentBuilder {
self.plugin_registry.as_deref(),
);
if subagents.is_empty() {
tool_config.tools.retain(|tc| tc.id != task_tool_id);
tool_config
.tools
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
task_stripped = true;
} else if self.prompt_audience == crate::prompt::context::PromptAudience::Subagent {
if let Some(task_tc) = tool_config
@@ -810,7 +822,13 @@ impl AgentBuilder {
.tools
.iter()
.any(|t| AGENT_TASK_CLASSIFIER_RE.is_match(t));
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
let task_deps = [
"task",
"agent_swarm",
"get_task_output",
"kill_task",
"wait_tasks",
];
let registered_tool_ids = tool_bridge_builder.known_tool_ids();
let present_kinds: std::collections::HashSet<ToolKind> =
tool_config.tools.iter().filter_map(|tc| tc.kind).collect();
@@ -925,7 +943,13 @@ impl AgentBuilder {
}
}
if definition.allowed_subagent_types.as_deref() == Some(&[]) {
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
let task_deps = [
"task",
"agent_swarm",
"get_task_output",
"kill_task",
"wait_tasks",
];
tool_config
.tools
.retain(|tc| !task_deps.contains(&short_tool_name(&tc.id)));
@@ -1601,6 +1625,11 @@ mod tests {
has_task, *subagents,
"[{label}] spawn_subagent presence should match subagents_enabled={subagents}; got tools: {names:?}"
);
let has_swarm = names.contains(&"agent_swarm");
assert_eq!(
has_swarm, *subagents,
"[{label}] agent_swarm fans out to subagents, so it must follow the same gate as spawn_subagent; got tools: {names:?}"
);
assert!(
names.contains(&"enter_plan_mode"),
"[{label}] enter_plan_mode must always be present (TUI plan-mode keybind needs it); got tools: {names:?}"
+9 -15
View File
@@ -1,24 +1,18 @@
//! Compaction policy — threshold, model, and memory flush configuration.
/// Session-level compaction policy.
///
/// Controls when and how the session's conversation is compacted
/// to free up context window space, and whether a memory flush
/// runs before each compaction.
/// Controls when and how the session's conversation is compacted to free up
/// context window space, and whether a memory flush runs before each compaction.
#[derive(Debug, Clone)]
pub struct CompactionPolicy {
/// Percentage of context window that triggers auto-compaction.
/// E.g., 85 means compact when 85% of the context window is used.
pub auto_compact_threshold_percent: u32,
/// Model to use for generating the compaction summary.
/// None = use the session's current model.
/// `None` uses the session's current model.
pub compact_model: Option<String>,
/// Whether to run a memory flush turn before each compaction.
/// When enabled, the session actor asks the model to summarize
/// important information from the conversation before it's compacted.
/// Requires the memory system to be enabled.
/// Run a memory flush turn before each compaction: the session actor asks
/// the model to summarize important information from the conversation
/// before it is discarded. Requires the memory system to be enabled.
pub memory_flush_enabled: bool,
/// Per-compaction wall-clock budget (seconds); a generation exceeding it is
@@ -27,9 +21,9 @@ pub struct CompactionPolicy {
/// Prefire two-pass compaction: when usage approaches the threshold,
/// speculatively summarize the history prefix in the background (pass 1);
/// at compaction, summarize NOTE₁ + the recent tail (pass 2). Resolved from
/// config (`two_pass_compaction` flag) at session build; `false` keeps the
/// legacy single-pass path. Default `false` (real sessions set it from config).
/// at compaction, summarize NOTE₁ + the recent tail (pass 2). `false`
/// selects the single-pass path. Real sessions resolve this from the
/// `two_pass_compaction` config flag at session build.
pub two_pass_enabled: bool,
}
+14 -4
View File
@@ -154,6 +154,11 @@ fn task_tool_config() -> ToolConfig {
.with_name("spawn_subagent")
.with_param_rename("run_in_background", "background")
}
/// Swarm tool. Keeps its registry name: unlike `task` it has no CLI-specific
/// alias, and the name is what the model is told to reuse for `resume_agent_ids`.
fn agent_swarm_tool_config() -> ToolConfig {
ToolConfig::from(&kigi::AgentSwarmTool)
}
/// Task output tool renamed for clarity:
/// `get_task_output` → `get_command_or_subagent_output`.
fn task_output_tool_config() -> ToolConfig {
@@ -270,6 +275,7 @@ fn default_kigi_toolset() -> ToolServerConfig {
task_output_tool_config(),
wait_tasks_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
(&kigi::SchedulerListTool).into(),
@@ -318,6 +324,7 @@ pub fn kigi_hashline_toolset(
task_output_tool_config(),
wait_tasks_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::WebSearchTool).into(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
@@ -400,6 +407,7 @@ fn kigi_plan_toolset() -> ToolServerConfig {
(&kigi::TodoWriteTool).into(),
task_output_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
(&kigi::SchedulerListTool).into(),
@@ -428,6 +436,7 @@ fn orchestrator_toolset() -> ToolServerConfig {
(&kigi::ListDirTool).into(),
(&kigi::GrepTool).into(),
task_tool_config(),
agent_swarm_tool_config(),
task_output_tool_config(),
wait_tasks_tool_config(),
kill_task_tool_config(),
@@ -497,6 +506,7 @@ fn kigi_ask_user_toolset() -> ToolServerConfig {
task_output_tool_config(),
wait_tasks_tool_config(),
task_tool_config(),
agent_swarm_tool_config(),
(&kigi::SchedulerCreateTool).into(),
(&kigi::SchedulerDeleteTool).into(),
(&kigi::SchedulerListTool).into(),
@@ -1381,10 +1391,10 @@ impl AgentDefinition {
///
/// Used by the runtime turn-end TodoGate to gate firing on sessions
/// whose prompt actually references the rules the gate's reminder
/// text invokes. The block has been removed from every built-in
/// template, so this returns `false` unconditionally. Kept as a
/// helper so the gate's call-site stays stable in case the block
/// is reintroduced behind a future flag.
/// text invokes. No built-in template carries the block, so this
/// returns `false` unconditionally. Kept as a helper so the gate's
/// call-site stays stable in case the block is reintroduced behind
/// a future flag.
pub fn carries_task_completion_discipline(
&self,
_audience: crate::prompt::context::PromptAudience,
+53 -13
View File
@@ -39,7 +39,7 @@ pub fn project_agent_dirs_in(chain_dirs: &[PathBuf]) -> Vec<PathBuf> {
crate::repo::existing_subdirs_along(chain_dirs, PROJECT_AGENT_SUBDIRS)
}
// ── Subagent entry types ─────────────────────────────────────────────
// Subagent entry types
/// A subagent entry for the Task tool description and spawn-time validation.
#[derive(Debug, Clone)]
@@ -61,7 +61,7 @@ pub enum SubagentSource {
UserDefined { scope: AgentScope },
}
// ── all_subagents ────────────────────────────────────────────────────
// all_subagents
/// Build the complete list of enabled subagents.
///
@@ -102,7 +102,6 @@ fn merge_subagents(
}
}
// 1. Seed with built-in subagents
let mut entries: Vec<SubagentEntry> = BuiltinAgentName::subagent_variants()
.iter()
.map(|b| {
@@ -117,7 +116,6 @@ fn merge_subagents(
})
.collect();
// 2. Merge in discovered user-defined agents.
//
// IMPORTANT: Only project-level agents can shadow built-ins. This matches
// the runtime spawn precedence in by_name_in_cwd():
@@ -173,7 +171,6 @@ fn merge_subagents(
}
}
// 3. Filter by toggle (omitted = enabled)
entries
.into_iter()
.filter(|e| toggle.get(&e.name).copied().unwrap_or(true))
@@ -255,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`.
@@ -301,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);
}
@@ -359,7 +369,7 @@ fn source_from_agent_def(def: &AgentDefinition) -> ConfigSource {
}
}
// ── Plugin-aware variants ─────────────────────────────────────────────
// Plugin-aware variants
/// Build the complete list of enabled subagents, including plugin agents.
pub fn all_subagents_with_plugins(
@@ -830,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();
@@ -1031,7 +1065,7 @@ mod tests {
assert_eq!(def.scope, AgentScope::BuiltIn);
}
// ── all_subagents / merge_subagents tests ───────────────────────
// all_subagents / merge_subagents tests
/// Helper: build a minimal synthetic AgentDefinition for testing merge logic.
fn synthetic_agent(name: &str, desc: &str, scope: AgentScope) -> AgentDefinition {
@@ -1110,7 +1144,8 @@ mod tests {
AgentScope::Project,
)];
let entries = merge_subagents(discovered, &HashMap::new());
assert_eq!(entries.len(), 4); // 3 built-ins + 1 user
// 3 built-ins + 1 user
assert_eq!(entries.len(), 4);
let cr = entries.iter().find(|e| e.name == "code-reviewer").unwrap();
assert_eq!(cr.description, "Reviews code");
assert_eq!(
@@ -1131,7 +1166,8 @@ mod tests {
)];
let toggle = HashMap::from([("code-reviewer".to_string(), false)]);
let entries = merge_subagents(discovered, &toggle);
assert_eq!(entries.len(), 3); // only built-ins
// only built-ins
assert_eq!(entries.len(), 3);
assert!(entries.iter().all(|e| e.name != "code-reviewer"));
}
@@ -1143,7 +1179,8 @@ mod tests {
AgentScope::Project,
)];
let entries = merge_subagents(discovered, &HashMap::new());
assert_eq!(entries.len(), 3); // still 3 — replaced, not appended
// still 3 — replaced, not appended
assert_eq!(entries.len(), 3);
let explore = entries.iter().find(|e| e.name == "explore").unwrap();
assert_eq!(explore.description, "Custom explore agent");
assert_eq!(
@@ -1180,7 +1217,8 @@ mod tests {
AgentScope::User,
)];
let entries = merge_subagents(discovered, &HashMap::new());
assert_eq!(entries.len(), 3); // still 3 built-ins
// still 3 built-ins
assert_eq!(entries.len(), 3);
let explore = entries.iter().find(|e| e.name == "explore").unwrap();
// Should still be the built-in, not the user-level agent
assert!(
@@ -1215,7 +1253,8 @@ mod tests {
AgentScope::User,
)];
let entries = merge_subagents(discovered, &HashMap::new());
assert_eq!(entries.len(), 4); // 3 built-ins + 1 user
// 3 built-ins + 1 user
assert_eq!(entries.len(), 4);
// Verify ordering: built-ins first, then user
assert!(matches!(&entries[0].source, SubagentSource::Builtin(_)));
assert!(matches!(&entries[1].source, SubagentSource::Builtin(_)));
@@ -1262,7 +1301,8 @@ mod tests {
// Simulate: discover() skips invalid files (returns empty for that file).
// So if a user's explore.md is invalid, discover() won't include it,
// and the built-in explore remains.
let discovered = vec![]; // no valid user agents discovered
// no valid user agents discovered
let discovered = vec![];
let entries = merge_subagents(discovered, &HashMap::new());
assert_eq!(entries.len(), 3);
let explore = entries.iter().find(|e| e.name == "explore").unwrap();
+9 -11
View File
@@ -1,36 +1,34 @@
//! Error types for agent construction.
/// Errors that can occur during Agent construction.
#[derive(Debug, thiserror::Error)]
pub enum AgentBuildError {
/// Failed to parse the agent definition file (bad YAML frontmatter,
/// missing closing `---`, or invalid Markdown structure).
/// Bad YAML frontmatter, a missing closing `---`, or invalid Markdown
/// structure in the definition file.
#[error("failed to parse agent definition: {0}")]
ParseError(String),
/// Required fields are missing from the definition (name, description).
#[error("missing required field in agent definition: {0}")]
MissingField(String),
/// A tool name override references a tool that doesn't exist in the
/// registry (typo in the definition's `toolNameOverrides`).
/// Usually a typo in the definition's `toolNameOverrides`.
#[error("tool name override references nonexistent tool '{0}'")]
UnknownToolOverride(String),
/// IO error during AGENTS.md or skills discovery.
#[error("IO error during agent construction: {0}")]
IoError(#[from] std::io::Error),
/// MiniJinja template rendering failed (extend or full mode).
/// Includes line numbers and context from the template.
/// The per-session runtime could not be built — fd or thread
/// exhaustion (EMFILE/EAGAIN), not a definition problem.
#[error("failed to build the session runtime: {0}")]
RuntimeBuild(std::io::Error),
/// Carries template line numbers and surrounding context.
#[error("template rendering error: {0}")]
MiniJinjaError(#[from] minijinja::Error),
/// Tool registry error (e.g., unsatisfied requirements during finalization).
#[error("tool error: {0}")]
ToolError(String),
/// A configuration value is present but invalid (e.g. `max_turns = 0`).
#[error("invalid configuration: {0}")]
InvalidConfig(String),
}
-1
View File
@@ -1,6 +1,5 @@
//! Agent builder, definition parsing, and system prompt assembly.
//!
//! This crate extracts a first-class `Agent` type from `kigi-shell`.
//! An `Agent` bundles tools, system prompt, system-reminder policy,
//! compaction policy, and model configuration into a single, portable
//! object that any host can consume.
@@ -21,7 +21,7 @@ use sha2::{Digest, Sha256};
use super::manifest::{ManifestLoadResult, PluginManifest, load_manifest, name_from_dirname};
use super::trust::TrustStore;
// ── Public types ──────────────────────────────────────────────────────
// Public types
/// Where a plugin was discovered from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -204,12 +204,12 @@ impl DiscoveryConfig {
}
}
// ── Discovery entry point ─────────────────────────────────────────────
// Discovery entry point
/// User plugin directories in priority order: `$KIGI_SHARE_DIR/plugins` then
/// `~/.claude/plugins`.
///
/// Unlike agent discovery, plugins are intentionally NOT discovered from a
/// Unlike agent discovery, plugins are deliberately NOT discovered from a
/// legacy `~/.kigi/plugins`: plugin trust, persisted plugin-data, and install
/// paths all resolve under `kigi_home()`, so a plugin scanned from the legacy
/// tree would appear untrusted and lose its persisted state. Keeping plugins on
@@ -483,7 +483,7 @@ pub fn discover_plugins(
candidates
}
// ── Internal helpers ──────────────────────────────────────────────────
// Internal helpers
/// Scan a plugins parent directory (e.g. `~/.kigi/plugins/`) and collect
/// each subdirectory as a plugin candidate.
@@ -510,7 +510,8 @@ fn scan_plugin_dir(
let mut subdirs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir()) // follows symlinks
// follows symlinks
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect();
@@ -787,7 +788,7 @@ fn resolve_name_conflicts(candidates: &mut Vec<DiscoveredPlugin>) {
}
}
// ── Compat installed_plugins.json types ───────────────────────────────
// Compat installed_plugins.json types
/// Compat `installed_plugins.json` format.
#[derive(serde::Deserialize)]
@@ -1351,7 +1352,7 @@ mod tests {
let parts: Vec<&str> = id.0.split('/').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "user");
assert_eq!(parts[1].len(), 8); // 8 hex chars
assert_eq!(parts[1].len(), 8);
assert_eq!(parts[2], "my-plugin");
}
@@ -561,7 +561,7 @@ pub struct UpdateResult {
/// Status of an update attempt.
pub enum UpdateStatus {
/// Repo was updated successfully.
/// Repo updated successfully.
Updated(UpdateResult),
/// Repo is pinned to a tag or commit — no automatic update.
Pinned { ref_name: String },
@@ -633,7 +633,7 @@ pub fn update_repo(repo_key: &str, repo: &InstalledRepo) -> Result<UpdateStatus,
let new_commit = read_head_commit(repo_path);
let changed = old_commit.as_deref() != new_commit.as_deref();
// Re-discover plugins (new ones may have been added)
// Re-discover plugins: the pull may bring new ones
let plugins = discover_plugins_in_dir(repo_path, subdir.as_deref())?;
Ok(UpdateStatus::Updated(UpdateResult {
@@ -296,7 +296,8 @@ mod tests {
fn prefilter_handles_invalid_json() {
let json = "not valid json{";
let (filtered, skipped) = prefilter_unsupported_events(json);
assert_eq!(filtered, json); // returned as-is
// returned as-is
assert_eq!(filtered, json);
assert!(skipped.is_empty());
}
@@ -500,7 +501,7 @@ mod tests {
/// reference resolves to the plugin root exactly once, and the result
/// contains no leftover `$` placeholders. This is the contract the
/// hooks_adapter has long held, and it must continue to hold
/// now that `parse_hook_file` itself does an env-expansion pass with
/// because `parse_hook_file` itself does an env-expansion pass with
/// the per-hook `extra_env`. The first pass (in `parse_hook_file`)
/// runs against an EMPTY `extra_env` for plugin hooks (the adapter
/// only fills it in afterwards), so the placeholder survives that
@@ -297,7 +297,7 @@ impl InstallRegistry {
}
}
// ── Errors ────────────────────────────────────────────────────────────
// Errors
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
@@ -323,7 +323,7 @@ pub enum InstallError {
InstallFailed { detail: String },
}
// ── Tests ─────────────────────────────────────────────────────────────
// Tests
#[cfg(test)]
mod tests {
@@ -154,7 +154,7 @@ pub struct PluginManifest {
#[serde(default)]
pub keywords: Vec<String>,
// ── Component path overrides (supplement convention dirs) ──────
// Component path overrides (supplement convention dirs)
#[serde(default)]
pub skills: Option<PathOrPaths>,
#[serde(default)]
@@ -247,7 +247,7 @@ impl PluginManifest {
/// Log informational messages about manifest features.
///
/// Called during discovery. Inline hooks and MCP servers are now
/// Called during discovery. Inline hooks and MCP servers are
/// fully supported; this method logs when they are detected.
pub fn warn_unsupported_features(&self, plugin_name: &str) {
if self.inline_hooks().is_some() {
@@ -287,7 +287,7 @@ fn resolve_dirs(
}
}
// ── Manifest loading ──────────────────────────────────────────────────
// Manifest loading
/// Manifest search order within a plugin directory.
const MANIFEST_PATHS: &[&str] = &[
@@ -375,7 +375,7 @@ pub fn normalize_inline_mcp_servers(value: &serde_json::Value) -> serde_json::Va
serde_json::json!({ "mcpServers": inner })
}
// ── Errors ────────────────────────────────────────────────────────────
// Errors
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
@@ -530,7 +530,8 @@ mod tests {
);
assert_eq!(
name_from_dirname(Path::new("/path/to/---")),
None // all hyphens after trim
// all hyphens after trim
None
);
}
@@ -147,7 +147,7 @@ pub fn load_enabled_disabled_plugins(path: &Path) -> (Vec<String>, Vec<String>)
parse_enabled_disabled_plugins(&json)
}
// ── Compat known_marketplaces.json ────────────────────────────────────
// Compat known_marketplaces.json
/// Entry in `~/.claude/plugins/known_marketplaces.json`.
#[derive(serde::Deserialize)]
+2 -8
View File
@@ -1,15 +1,9 @@
//! Plugin system — discover, load, and manage plugins (including compat layouts).
//! Plugin discovery, loading, and registry.
//!
//! A plugin is a self-contained directory that bundles skills, agents,
//! MCP server configs, and hooks into a namespaced unit. Plugins can
//! MCP server configs, and hooks into a namespaced unit. Plugins can
//! live under `~/.kigi/plugins/`, `.kigi/plugins/` (project-level),
//! or be passed via `--plugin-dir` on the CLI.
//!
//! This module handles:
//! - `manifest` — parsing `plugin.json` manifests
//! - `discovery` — scanning the filesystem for plugin directories
//! - `trust` — project-plugin trust management
//! - `registry` — in-memory registry of active plugins
pub mod discovery;
pub mod git_install;
@@ -301,7 +301,7 @@ impl PluginRegistry {
}
}
// ── Shared handle for cross-thread reload ─────────────────────────────
// Shared handle for cross-thread reload
/// Thread-safe handle for plugin registry lifecycle.
///
@@ -456,7 +456,7 @@ impl SharedPluginRegistryHandle {
}
}
// ── Component counting helpers ────────────────────────────────────────
// Component counting helpers
/// Collect the SKILL.md paths that load from the given skill dirs.
///
@@ -793,9 +793,11 @@ mod tests {
&["enabled-plugin".to_string()],
);
assert_eq!(reg.len(), 2); // Both in registry
// Both in registry
assert_eq!(reg.len(), 2);
let active = reg.active_plugins();
assert_eq!(active.len(), 1); // Only enabled one is active
// Only enabled one is active
assert_eq!(active.len(), 1);
assert_eq!(active[0].name, "enabled-plugin");
// Disabled one is in list but marked disabled
@@ -876,9 +878,12 @@ mod tests {
let reg = PluginRegistry::from_discovered(plugins, &[], &[]);
let list = reg.list();
assert_eq!(list[0].name, "alpha"); // CliOverride = 0
assert_eq!(list[1].name, "beta"); // Project = 1
assert_eq!(list[2].name, "zebra"); // User = 2
// CliOverride = 0
assert_eq!(list[0].name, "alpha");
// Project = 1
assert_eq!(list[1].name, "beta");
// User = 2
assert_eq!(list[2].name, "zebra");
}
#[test]
@@ -935,7 +940,7 @@ mod tests {
assert_eq!(reg.mcp_server_owner("my-server"), Some("mcp-plugin"));
}
// ── Combined disabled + untrusted scenarios ─────────────────
// Combined disabled + untrusted scenarios
#[test]
fn disabled_project_plugin_excluded_from_active_and_enabled() {
@@ -961,7 +966,7 @@ mod tests {
let bad = reg.get("bad-plugin").unwrap();
assert!(!bad.enabled);
// trusted is now propagated from discovery (was false for Project scope)
// trusted is propagated from discovery (was false for Project scope)
assert!(!bad.trusted);
}
@@ -1166,7 +1171,7 @@ mod tests {
assert_eq!(config.disabled.len(), 2);
}
// ── Security: trust propagation from discovery ──────────────
// Security: trust propagation from discovery
#[test]
fn untrusted_project_plugin_excluded_from_active_even_when_enabled() {
@@ -1174,12 +1179,14 @@ mod tests {
// pre-populated enabledPlugins) but NOT trusted. It must NOT
// appear in active_plugins() so its hooks never fire.
let plugins = vec![
make_discovered("malicious", PluginScope::Project, false), // untrusted
// untrusted
make_discovered("malicious", PluginScope::Project, false),
];
let reg = PluginRegistry::from_discovered(
plugins,
&[],
&["malicious".to_string()], // attacker got it into enabled list
// attacker got it into enabled list
&["malicious".to_string()],
);
// Plugin is enabled but not trusted
@@ -129,7 +129,8 @@ impl TrustStore {
})?;
if !self.trusted.remove(&canonical) {
return Ok(()); // wasn't trusted
// wasn't trusted
return Ok(());
}
// Rewrite the entire file without the revoked path
@@ -176,7 +177,7 @@ impl TrustStore {
}
}
// ── Internal ──────────────────────────────────────────────────────
// Internal
fn read_trust_file(path: &Path) -> HashSet<PathBuf> {
let file = match std::fs::File::open(path) {
@@ -208,7 +209,7 @@ impl TrustStore {
}
}
// ── Errors ────────────────────────────────────────────────────────────
// Errors
#[derive(Debug, thiserror::Error)]
pub enum TrustError {
@@ -320,7 +321,8 @@ mod tests {
// This test checks the logic but can't easily mock $HOME.
// We verify the function exists and returns a boolean.
let result = TrustStore::is_config_path_auto_trusted(Path::new("/nonexistent/path"));
assert!(!result); // nonexistent path can't be canonicalized
// nonexistent path can't be canonicalized
assert!(!result);
}
#[test]
@@ -239,7 +239,7 @@ mod tests {
git2::Repository::init(path).unwrap();
}
// ── find_agent_files unit tests ─────────────────────────────────
// find_agent_files unit tests
#[test]
fn find_agent_files_finds_agents_md() {
@@ -320,7 +320,7 @@ mod tests {
assert!(files[1].to_string_lossy().contains("style.md"));
}
// ── format_agents_md_section tests ──────────────────────────────
// format_agents_md_section tests
#[test]
fn format_agents_md_section_empty_returns_none() {
@@ -370,7 +370,7 @@ mod tests {
);
}
// ── Feature 2: Workspace user AGENTS.md via read_agents_config ───
// Feature 2: Workspace user AGENTS.md via read_agents_config
#[tokio::test]
async fn read_agents_config_includes_workspace_user_agents_md() {
@@ -537,7 +537,7 @@ mod tests {
assert!(!section.contains("globs:"));
}
// ── .claude/CLAUDE.md integration tests ─────────────────────────
// .claude/CLAUDE.md integration tests
#[tokio::test]
async fn read_agents_config_discovers_claude_subdir_claude_md() {
@@ -224,9 +224,9 @@ impl PromptContext {
}
/// Format the personas section content.
///
/// Always returns `None` — the `persona` parameter has been removed
/// from the task tool input, so persona summaries are no longer
/// injected into the conversation.
/// Always returns `None` — the task tool input carries no `persona`
/// parameter, so persona summaries are never injected into the
/// conversation.
pub fn format_personas_section(&self) -> Option<String> {
None
}
@@ -4,7 +4,6 @@ use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::{Path, PathBuf};
pub fn build_gitignore(repo_root: Option<&Path>) -> Option<Gitignore> {
// No repo root → no gitignore rules to apply.
let root = repo_root?;
let mut builder = GitignoreBuilder::new(root);
@@ -33,5 +32,5 @@ fn get_global_gitignore_path() -> Option<PathBuf> {
git2::Config::open_default()
.ok()
.and_then(|cfg| cfg.get_path("core.excludesFile").ok())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".gitignore")))
.or_else(|| std::env::home_dir().map(|h| h.join(".gitignore")))
}
+25 -22
View File
@@ -726,7 +726,7 @@ mod tests {
fs::write(dir.join("SKILL.md"), content).unwrap();
}
// ── Server-synced skills (injected server_skill_dirs) ────────────────
// Server-synced skills (injected server_skill_dirs)
#[tokio::test]
async fn server_skills_discovered_and_shadowed_by_local() {
@@ -826,7 +826,7 @@ mod tests {
);
}
// ── Feature 3: Recursive skill reading ──────────────────────────────
// Feature 3: Recursive skill reading
#[test]
fn find_skill_paths_flat_layout() {
@@ -960,7 +960,7 @@ mod tests {
assert!(path_strs.iter().any(|p| p.contains("child/SKILL.md")));
}
// ── extract_first_paragraph ──────────────────────────────────────
// extract_first_paragraph
#[test]
fn first_paragraph_simple() {
@@ -1001,7 +1001,7 @@ mod tests {
assert!(extract_first_paragraph(body).is_none());
}
// ── UTF-8 safe body truncation ──────────────────────────────────
// UTF-8 safe body truncation
#[test]
fn description_fallback_does_not_panic_on_multibyte_boundary() {
@@ -1012,10 +1012,12 @@ mod tests {
// Strategy: fill with ASCII up to near the limit, then pack 4-byte
// emoji right at the boundary.
let prefix = "# Heading\n\n";
let filler_len = MAX_BODY_PEEK_BYTES - prefix.len() - 4; // leave room for emoji at boundary
// leave room for emoji at boundary
let filler_len = MAX_BODY_PEEK_BYTES - prefix.len() - 4;
let filler = "a".repeat(filler_len);
// Each emoji is 4 bytes. Place several so one straddles the 2048 mark.
let emoji_run = "\u{1F600}".repeat(10); // 40 bytes of emoji
// 40 bytes of emoji
let emoji_run = "\u{1F600}".repeat(10);
let body = format!("{prefix}{filler}{emoji_run}");
assert!(body.len() > MAX_BODY_PEEK_BYTES, "body must exceed limit");
@@ -1041,7 +1043,8 @@ mod tests {
// Body (after frontmatter): heading + paragraph with multibyte chars
// exceeding 2048 bytes.
let long_paragraph = "\u{00E9}".repeat(MAX_BODY_PEEK_BYTES); // 2-byte chars
// 2-byte chars
let long_paragraph = "\u{00E9}".repeat(MAX_BODY_PEEK_BYTES);
let content = format!("---\nname: emoji-skill\n---\n# Test\n\n{long_paragraph}\n");
fs::write(skill_dir.join("SKILL.md"), &content).unwrap();
@@ -1055,7 +1058,7 @@ mod tests {
);
}
// ── Frontmatter parsing (existing coverage + regression) ─────────
// Frontmatter parsing (existing coverage + regression)
#[test]
fn parse_valid_frontmatter() {
@@ -1122,7 +1125,7 @@ mod tests {
assert!(parsed.effort.is_none());
}
// ── agentskills.io spec parity ────────────────────────────────
// agentskills.io spec parity
#[test]
fn parse_license_and_compatibility() {
@@ -1296,7 +1299,7 @@ mod tests {
));
}
// ── Feature 1: Workspace user skills via list_skills ─────────────
// Feature 1: Workspace user skills via list_skills
/// Helper: initialize a bare git repo at `path` so git2::Repository::discover works.
fn init_git_repo(path: &Path) {
@@ -1423,7 +1426,7 @@ mod tests {
);
}
// ── collect_config_skills ────────────────────────────────────────
// collect_config_skills
#[test]
fn collect_config_skills_from_directory() {
@@ -1530,7 +1533,7 @@ mod tests {
}
}
// ── filter_skills ────────────────────────────────────────────────
// filter_skills
fn make_skill(name: &str, path: &str) -> SkillInfo {
SkillInfo {
@@ -1622,7 +1625,7 @@ mod tests {
assert_eq!(skills[0].plugin_name.as_deref(), Some("plugin-dev"));
}
// ── Manifest `skills` entries pointing directly at skill dirs ──
// Manifest `skills` entries pointing directly at skill dirs
fn make_registry_with_skill_dirs(
name: &str,
@@ -2006,11 +2009,11 @@ mod tests {
);
}
// discover_skills_for_paths and dedup_by_canonical_path tests removed --
// these functions now live in kigi-tools::implementations::skills::discovery
// and kigi-tools::types::skill_discovery_tracker, tested there.
// discover_skills_for_paths and dedup_by_canonical_path live in
// kigi-tools::implementations::skills::discovery and
// kigi-tools::types::skill_discovery_tracker, and are tested there.
// ── Disabled skills marking ─────────────────────────────────────
// Disabled skills marking
#[tokio::test]
async fn disabled_config_marks_skill_enabled_false() {
@@ -2091,7 +2094,7 @@ mod tests {
);
}
// ── Bundled skills discovery ─────────────────────────────────────
// Bundled skills discovery
#[tokio::test]
async fn bundled_skills_are_discovered() {
@@ -2180,7 +2183,7 @@ mod tests {
);
}
// ── Command file discovery ────────────────────────────────────────
// Command file discovery
/// Regression: project `.claude/commands` often sits under a full `.claude/**`
/// gitignore with only `!.claude/skills/**` re-included (local-only vendor
@@ -2312,7 +2315,7 @@ mod tests {
assert!(deploy[0].path.contains("SKILL.md"));
}
// ── Plugin skill identity ─────────────────────────────
// Plugin skill identity
fn min_plugin(name: &str) -> crate::plugins::LoadedPlugin {
use crate::plugins::discovery::PluginId;
@@ -2430,7 +2433,7 @@ mod tests {
);
}
// ── collect_skill_config_dirs vendor gating ────────────
// collect_skill_config_dirs vendor gating
#[test]
fn collect_skill_config_dirs_gates_vendor_dirs() {
@@ -2461,7 +2464,7 @@ mod tests {
assert!(ends_with(&dirs, ".kigi"), "kigi must remain: {dirs:?}");
}
// ── Same-scope frontmatter-name collisions (copied skill dirs) ──────
// Same-scope frontmatter-name collisions (copied skill dirs)
fn named_skill(name: &str, path: &str, scope: SkillScope) -> SkillInfo {
SkillInfo {
@@ -1,26 +1,10 @@
//! System prompts for built-in subagent profiles.
//!
//!
//! ## Tool name resolution
//!
//! All tool names in these prompts use the `${{ tools.by_kind.* }}` template
//! syntax from the `TemplateRenderer`. When the prompt is rendered via
//! `PromptContext::render()` → `ToolBridge::render_prompt()`, MiniJinja
//! resolves each variable to the current session's tool names.
//!
//! This means:
//! - Tool names are NEVER hardcoded — they adapt to name overrides and
//! alternate tool namespaces
//! - If a tool kind is absent from the renderer's context, MiniJinja
//! resolves it to an empty string (templates can also use
//! `${%- if tools.by_kind.X %}` conditionals to hide entire sections)
//!
//! Tool-kind mapping (common names → ToolKind):
//! Read → `${{ tools.by_kind.read }}`
//! Write/Edit → `${{ tools.by_kind.edit }}`
//! Glob → `${{ tools.by_kind.list }}`
//! Grep → `${{ tools.by_kind.search }}`
//! Bash → `${{ tools.by_kind.execute }}`
//! WebSearch → `${{ tools.by_kind.web_search }}`
//! Tool names inside these prompts are never hardcoded: they are
//! `${{ tools.by_kind.* }}` template variables that MiniJinja resolves to the
//! session's actual tool names during `ToolBridge::render_prompt()`, so they
//! follow name overrides and alternate namespaces. A kind that is absent from
//! the renderer context resolves to an empty string, which is why prompts guard
//! whole sections with `${%- if tools.by_kind.X %}`.
pub use kigi_tool_types::{EXPLORE_PROMPT, GENERAL_PURPOSE_PROMPT, PLAN_PROMPT};
@@ -149,7 +149,7 @@ mod tests {
.expect("codex template render failed")
}
// ── Variable substitution ───────────────────────────────────────
// Variable substitution
#[test]
fn test_variable_substitution_tool_kind() {
@@ -171,7 +171,7 @@ mod tests {
assert_eq!(result, "OS: macos, Shell: /bin/zsh");
}
// ── Conditionals ────────────────────────────────────────────────
// Conditionals
#[test]
fn test_conditional_tool_present() {
@@ -205,7 +205,7 @@ mod tests {
assert_eq!(result, "Use {{ literal_braces }} in prose.");
}
// ── Tool name overrides ─────────────────────────────────────────
// Tool name overrides
#[test]
fn test_tool_name_override() {
@@ -225,7 +225,7 @@ mod tests {
assert_eq!(result, "Use view_file and Edit.");
}
// ── Base template rendering ─────────────────────────────────────
// Base template rendering
#[test]
fn test_base_template_renders() {
@@ -355,7 +355,7 @@ mod tests {
);
}
// ── Required sections regression ────────────────────────────────
// Required sections regression
#[test]
fn test_base_template_contains_required_sections() {
@@ -380,7 +380,7 @@ mod tests {
);
}
// ── Mid-session mode switching ──────────────────────────────────
// Mid-session mode switching
#[test]
fn test_mid_session_switch_concise_to_full() {
@@ -430,7 +430,7 @@ mod tests {
);
}
// ── Determinism ─────────────────────────────────────────────────
// Determinism
#[test]
fn test_prompt_deterministic_across_renders() {
@@ -451,7 +451,7 @@ mod tests {
assert_eq!(a, b, "Full mode rendering must be deterministic");
}
// ── Disabled tools ──────────────────────────────────────────────
// Disabled tools
#[test]
fn test_disabled_tools_omit_sections() {
@@ -469,11 +469,11 @@ mod tests {
);
}
// ── Memory section ──────────────────────────────────────────────
// Memory section
#[test]
fn test_memory_enabled_does_not_render_memory_section() {
// The <memory> section was removed from the minimal base prompt.
// The <memory> section is absent from the minimal base prompt.
// Even when the memory tools are registered AND memory_enabled=true,
// the trimmed template must not render a memory section. (Complements
// test_memory_disabled_omits_memory_section, which covers the default.)
@@ -514,7 +514,7 @@ mod tests {
);
}
// ── Web search disabled ─────────────────────────────────────────
// Web search disabled
#[test]
fn test_web_search_disabled_renders_without_crash() {
@@ -534,7 +534,7 @@ mod tests {
);
}
// ── Apply-patch template rendering ───────────────────────────────────
// Apply-patch template rendering
#[test]
fn test_apply_patch_template_renders() {
@@ -634,9 +634,9 @@ mod tests {
assert_eq!(a, b, "Subagent template rendering must be deterministic");
}
// ── Task completion discipline ─────────────────────────────────
// Task completion discipline
//
// The `<task_completion_discipline>` block was removed from both
// The `<task_completion_discipline>` block is absent from both
// base and subagent templates. These tests pin the deletion so the
// block doesn't accidentally come back, and so the runtime TodoGate
// doesn't start firing reminders that reference a non-existent
@@ -681,7 +681,7 @@ mod tests {
assert_template_size_under(&prompt, "subagent");
}
// ── Guard invariant ─────────────────────────────────────────────
// Guard invariant
// Every `${{ tools.by_kind.X }}` must sit inside a `${%- if ... %}`
// whose condition requires X (contains `tools.by_kind.X` at a word
// boundary, with no top-level ` or `). If violated, X could render
@@ -770,12 +770,12 @@ mod tests {
assert_guards(&apply_patch_template(), "apply_patch_prompt.md");
}
// ── Combination sweep ───────────────────────────────────────────
// Combination sweep
// Belt-and-braces: renders the base template across tool-kind subsets
// and asserts no raw template tokens leak. The static guard test above
// is the authoritative check; this one just catches syntax drift.
// ── is_non_interactive gating ──────────────────────────────────
// is_non_interactive gating
// Headless / SDK / stdio / generic-ACP sessions have no human typing
// into a TUI prompt, so the `! <command>` shell-prefix tip and the
// `<user_guide>` TUI pointer are noise. Those sections must drop out
@@ -783,7 +783,7 @@ mod tests {
#[test]
fn interactive_renders_shell_prefix_tip_and_user_guide() {
// The `! <command>` shell-prefix tip was removed from the minimal
// The `! <command>` shell-prefix tip is absent from the minimal
// prompt. The <user_guide> block still renders for interactive
// sessions only, so that's what we assert here.
let mut p = default_placeholders();
@@ -334,7 +334,7 @@ mod tests {
assert_eq!(original, loaded);
}
}
/// A status under the cap passes through unchanged (trim is a no-op for
/// A status under the cap passes through `unchanged` (trim is a no-op for
/// real `git status --short --branch` output, which starts with `##`).
#[test]
fn normalize_git_status_passthrough_under_limit() {
@@ -48,7 +48,7 @@ mod tests {
use super::*;
use std::fs;
// ── resolve_workspace_user_dir (pure, no env vars) ───────────────
// resolve_workspace_user_dir (pure, no env vars)
#[test]
fn resolve_returns_none_for_empty_root() {
@@ -126,7 +126,7 @@ mod tests {
assert_eq!(result, Some(user_dir));
}
// ── workspace_user_relpath ───────────────────────────────────────
// workspace_user_relpath
#[test]
fn bare_username_is_nested_under_x() {
+30 -53
View File
@@ -1,10 +1,8 @@
//! Shared git-repo dir-chain primitive.
//!
//! One `git2` discovery + one cwd→root walk, reused across the many repo-local
//! config marker checks the folder-trust gate runs back-to-back. Lives in its
//! own module (rather than `discovery`) because it is a generic repo-walk
//! primitive consumed cross-crate by `kigi-workspace`, not agent-definition
//! discovery.
//! Lives in its own module rather than `discovery` because it is a generic
//! repo-walk primitive consumed cross-crate by `kigi-workspace`, not
//! agent-definition discovery.
use std::path::{Path, PathBuf};
@@ -14,42 +12,28 @@ use std::path::{Path, PathBuf};
///
/// The folder-trust gate's `repo_configs_present` probes a dozen repo-local
/// code-exec markers (`.mcp.json`, `.kigi/config.toml`, `.claude/settings.json`,
/// project plugin/agent dirs, …) back-to-back on the agent startup path. Each
/// marker walker used to run its own `discover` + cwd→root walk; sharing one
/// `RepoDirChain` collapses that to a single traversal (each redundant syscall
/// is taxed 10-100x on Windows, and on a non-git dir each `discover` walks to
/// the filesystem root). Both the gate and the real loaders consume the same
/// chain via `*_in` walker variants, so detection can't drift from loading.
///
/// The public cwd-taking delegators (`find_project_configs`,
/// `project_plugin_dirs`, `project_agent_dirs`, …) now resolve through this
/// chain too, so their non-gate callers (config watcher, reloader, the mcp/
/// config loaders, inspect, upload, mcp_doctor) gain the per-level canonicalize
/// below. That is deliberate: all those callers are cold (startup / file-change /
/// session-setup / manual commands), never per-keystroke, and the canonical stop
/// is strictly more correct.
/// project plugin/agent dirs, …) back-to-back on the agent startup path, so a
/// per-walker discovery + walk is a real cost: each redundant syscall is taxed
/// 10-100x on Windows, and on a non-git dir each `discover` walks to the
/// filesystem root. Both the gate and the real loaders consume the same chain
/// via `*_in` walker variants, so detection can't drift from loading.
///
/// Outside a git repo `git_root` is `None` and `dirs` is just `[cwd]`, matching
/// every walker's no-repo branch (probe `cwd` only).
#[derive(Debug, Clone)]
pub struct RepoDirChain {
/// Git worktree root (`workdir`), or `None` when `cwd` is not inside a repo.
pub git_root: Option<PathBuf>,
/// `cwd` up to and including `git_root`, cwd-first (`[cwd]` with no repo).
pub dirs: Vec<PathBuf>,
}
impl RepoDirChain {
/// Resolve the chain for `cwd`: ONE `git2` discovery + ONE upward walk.
pub fn resolve(cwd: &Path) -> Self {
let git_root = git2::Repository::discover(cwd)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()))
// Home-is-a-git-repo (dotfiles in $HOME): a discovery that walks up
// to $HOME must NOT treat the whole home subtree as one repo, or
// home-level `.kigi`/`.mcp.json`/plugins would look repo-local. Drop
// it so cwd is handled as no-repo (probe cwd only). Home is compared
// canonically to match the symlink handling in the walk below.
// Dotfiles in $HOME make home itself a repo; treating that subtree
// as repo-local would promote home-level `.kigi`/`.mcp.json`/plugins
// to project config. Dropping the root makes cwd behave as no-repo.
.filter(|root| !is_home_dir(root));
let mut dirs = Vec::new();
@@ -57,11 +41,10 @@ impl RepoDirChain {
// Canonicalize only for the stop test so a symlinked cwd/ancestor
// still halts AT the worktree root instead of over-walking to the
// filesystem root; pushed dirs keep their original spelling (callers
// `join` markers onto them, which resolve the same either way). The
// per-level canonicalize is required to stop at root through a
// symlinked ancestor while keeping raw spelling — do NOT reduce to a
// 2-call `starts_with` variant (it would mis-handle a mid-chain
// absolute symlink and reintroduce the over-walk).
// `join` markers onto them, which resolve the same either way).
// Canonicalizing per level is what makes that stop reliable — a
// 2-call `starts_with` variant mis-handles a mid-chain absolute
// symlink and over-walks.
let root_canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.clone());
let mut current = Some(cwd.to_path_buf());
while let Some(dir) = current {
@@ -81,9 +64,9 @@ impl RepoDirChain {
}
}
/// Whether `path` canonicalizes to the user's home directory. Local (not reused
/// from `kigi-workspace`, which depends on THIS crate) to keep the dep edge
/// one-way; backs the home-is-dotfiles guard in [`RepoDirChain::resolve`].
/// Whether `path` canonicalizes to the user's home directory. Duplicated here
/// instead of reused from `kigi-workspace`, which depends on THIS crate, to keep
/// the dep edge one-way.
fn is_home_dir(path: &Path) -> bool {
let Some(home) = dirs::home_dir() else {
return false;
@@ -94,8 +77,7 @@ fn is_home_dir(path: &Path) -> bool {
/// Existing `<dir>/<subdir>` directories under each dir of a precomputed
/// cwd→git-root chain ([`RepoDirChain::dirs`]), in chain order (cwd-first, then
/// each `subdirs` entry in order). Shared body for the project plugin/agent dir
/// walkers so the byte-identical double-loop lives in one place.
/// each `subdirs` entry in order).
pub(crate) fn existing_subdirs_along(chain_dirs: &[PathBuf], subdirs: &[&str]) -> Vec<PathBuf> {
let mut found = Vec::new();
for dir in chain_dirs {
@@ -114,8 +96,8 @@ mod tests {
use super::*;
use serial_test::serial;
/// RAII guard: set an env var, restore the prior value (or unset) on drop,
/// so a test never leaves process-global env pointing at a dropped tempdir.
/// Restores the prior value (or unsets) on drop, so a test never leaves
/// process-global env pointing at a dropped tempdir.
struct EnvVarGuard {
key: &'static str,
prev: Option<std::ffi::OsString>,
@@ -140,8 +122,6 @@ mod tests {
#[test]
fn resolve_in_repo_yields_cwd_to_root_chain() {
// A git-init'd tmp with a 2-deep subdir: the chain is cwd→root inclusive,
// cwd-first, in the dirs' original spelling, and `git_root` is the root.
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
let nested = tmp.path().join("a").join("b");
@@ -156,8 +136,8 @@ mod tests {
tmp.path().to_path_buf(),
]
);
// `git_root` is the canonical worktree root (git2's `workdir`); compare by
// canonical form so a `/tmp`→`/private/tmp` symlink doesn't fail the test.
// git2's `workdir` is canonical, so compare canonically or a
// `/tmp`→`/private/tmp` symlink fails the test.
let root = chain.git_root.expect("inside a repo");
assert_eq!(
dunce::canonicalize(&root).unwrap(),
@@ -167,10 +147,9 @@ mod tests {
#[test]
fn resolve_outside_repo_is_cwd_only() {
// A non-git tmp: no discovery hit, so the chain is just `[cwd]` and there
// is no git root. Only assert the no-repo shape when the temp dir is
// genuinely outside any repo (a dev/CI checkout may place $TMPDIR inside
// a larger git worktree).
// Only assert the no-repo shape when the temp dir is genuinely outside
// any repo: a dev/CI checkout may place $TMPDIR inside a larger git
// worktree.
let tmp = tempfile::tempdir().unwrap();
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
@@ -184,10 +163,8 @@ mod tests {
#[test]
#[serial(home_env)]
fn resolve_treats_home_git_repo_as_no_repo() {
// Home-is-a-git-repo (dotfiles in $HOME): discovery walks up to $HOME,
// but the guard drops that root so a subdir resolves as no-repo (probe
// cwd only) instead of spanning the whole home subtree. $HOME is guarded
// (dirs::home_dir reads it) and canonicalized to match the guard.
// $HOME is process-global (`dirs::home_dir` reads it) so it needs the
// guard, and canonicalized to match the comparison in `is_home_dir`.
let tmp = tempfile::tempdir().unwrap();
let home = dunce::canonicalize(tmp.path()).unwrap();
git2::Repository::init(&home).unwrap();
@@ -203,8 +180,8 @@ mod tests {
#[test]
#[serial(home_env)]
fn resolve_keeps_non_home_git_root() {
// The guard is home-EXACT: a git root that is NOT $HOME still resolves
// normally (no over-trigger), so $HOME points at an unrelated dir here.
// The guard is home-EXACT, so $HOME points at an unrelated dir here to
// prove a non-home git root still resolves normally.
let home = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", home.path());
let repo = tempfile::tempdir().unwrap();
@@ -1,22 +1,14 @@
//! Reminder policy — wraps kigi-tools reminder config.
/// Default per-prompt fire cap for the runtime turn-end TodoGate. Used
/// only as the default for `TodoGateConfig`; the runtime consumer reads
/// the live value from `ReminderPolicy.todo_gate.max_fires_per_prompt`,
/// so this constant is NOT a hardcoded cap.
/// Seeds `TodoGateConfig::max_fires_per_prompt`; the gate reads the live value
/// from `ReminderPolicy.todo_gate`, never this constant.
pub const DEFAULT_TODO_GATE_MAX_FIRES: u32 = 2;
/// Session-level system reminder policy.
///
/// Controls whether system reminders are enabled and configures
/// the TodoNudge and TodoGate behavior.
#[derive(Debug, Clone)]
pub struct ReminderPolicy {
/// Whether system reminders are enabled at all.
pub enabled: bool,
/// Configuration for the periodic TodoWrite nudge reminder.
pub todo_nudge: TodoNudgeConfig,
/// Configuration for the runtime turn-end TodoGate.
pub todo_gate: TodoGateConfig,
}
@@ -30,17 +22,13 @@ impl Default for ReminderPolicy {
}
}
/// Configuration for the TodoWrite nudge reminder.
///
/// The system will remind the model to use `todo_write` when it
/// hasn't done so within a configurable number of turns.
/// Reminds the model to call `todo_write` once it has gone
/// `turns_since_todo_write` turns without one, then stays quiet for
/// `turns_between_reminders` turns.
#[derive(Debug, Clone)]
pub struct TodoNudgeConfig {
/// Whether the TodoNudge reminder is enabled.
pub enabled: bool,
/// Number of turns since last `todo_write` call before nudging.
pub turns_since_todo_write: u32,
/// Minimum turns between nudge reminders.
pub turns_between_reminders: u32,
}
@@ -54,24 +42,19 @@ impl Default for TodoNudgeConfig {
}
}
/// Configuration for the runtime turn-end TodoGate.
///
/// The gate inspects `TodoState` after every content-only assistant
/// message and forces another turn via `<system-reminder>` injection
/// if pending/unbacked-in-progress todos remain — see
/// Turn-end gate: inspects `TodoState` after every content-only assistant
/// message and forces another turn via `<system-reminder>` injection if
/// pending/unbacked-in-progress todos remain — see
/// `kigi-shell::session::acp_session::evaluate_todo_gate`.
///
/// **Disabled by default.** Operators opt in via the remote
/// `todo_gate_enabled = true` remote settings key, or via the
/// `--todo-gate` CLI flag (session-scoped force-enable, highest
/// precedence).
/// **Disabled by default.** Operators opt in via the `todo_gate_enabled`
/// remote settings key, or via the `--todo-gate` CLI flag (session-scoped
/// force-enable, highest precedence).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TodoGateConfig {
/// Whether the gate runs at all.
pub enabled: bool,
/// Hard cap on how many times the gate may fire per user prompt
/// before the next turn is allowed to end with `TurnOutcome::Completed`.
/// Bounds the worst-case extra inference cost.
/// Past this many fires per user prompt the next turn is allowed to end
/// with `TurnOutcome::Completed`, bounding worst-case extra inference cost.
pub max_fires_per_prompt: u32,
}
@@ -108,16 +91,11 @@ mod tests {
"TodoGate ships disabled; remote/local opt-in required"
);
assert_eq!(policy.todo_gate.max_fires_per_prompt, 2);
// The two reminder mechanisms are independent — flipping one
// must not change the other (regression guard).
assert!(policy.todo_nudge.enabled);
}
#[test]
fn todo_gate_enable_does_not_disturb_nudge() {
// Remote opt-in (or `[reminder.todo_gate] enabled = true` local
// config) flips the gate to on without touching the periodic
// TodoNudge as a side-effect.
let mut policy = ReminderPolicy::default();
policy.todo_gate.enabled = true;
assert!(policy.todo_gate.enabled);
+24 -32
View File
@@ -7,17 +7,16 @@ use reqwest::RequestBuilder;
use crate::visibility::HttpAuth;
/// Snapshot of the currently effective credentials. Used by callers
/// that build their own header maps (the OTel OTLP exporter) or that
/// need the bearer prefix for 401-attribution telemetry.
/// Snapshot of the currently effective credentials, for callers that build
/// their own header maps (the OTel OTLP exporter) or that need the bearer
/// prefix for 401-attribution telemetry.
#[derive(Clone, Debug, Default)]
pub struct CredentialSnapshot {
/// Bearer token. `None` when no auth is configured (CI / `--api-key` headless).
/// `None` when no auth is configured (CI / `--api-key` headless).
pub token: Option<String>,
/// User identifier matching the bearer token's owner. `None` when no auth
/// is configured or when the underlying provider has no concept of user
/// identity (`StaticAuthCredentialProvider`). Read by the OTel layer to
/// populate the `user.id` resource attribute.
/// Owner of `token`. `None` when no auth is configured or when the
/// provider has no concept of user identity
/// (`StaticAuthCredentialProvider`).
pub user_id: Option<String>,
/// `uuidv5(NAMESPACE_OID, deployment_key)`, set only for deployment-key auth.
pub deployment_id: Option<String>,
@@ -29,49 +28,42 @@ pub struct CredentialSnapshot {
///
/// Supertrait of `HttpAuth` so a single impl satisfies both this trait
/// (refresh-aware snapshot + 401 recovery) and the visibility seam
/// (header construction). Callers add headers via `HttpAuth::apply`.
/// (header construction).
#[async_trait::async_trait]
pub trait AuthCredentialProvider: HttpAuth + Send + Sync + 'static {
/// Return the current credential snapshot. Implementations should
/// issue a cheap disk re-read (`AuthManager::refresh`) before
/// snapshotting so callers see updates from sibling processes
/// (`kigi-desktop`, `kigi login`). The `token` field MUST mirror
/// the bearer that `HttpAuth::apply` would send on the wire so
/// 401-attribution prefixes match the actual request.
/// Implementations should issue a cheap disk re-read
/// (`AuthManager::refresh`) before snapshotting so callers see updates
/// from sibling processes (`kigi-desktop`, `kigi login`). The `token`
/// field MUST mirror the bearer that `HttpAuth::apply` would send on the
/// wire so 401-attribution prefixes match the actual request.
fn snapshot(&self) -> CredentialSnapshot;
/// Attempt to obtain a fresh token. Returns `true` if a different
/// token was obtained -- caller should retry the failed request once.
/// Returns `false` if no refresher is configured or refresh failed.
/// `true` if a different token was obtained, meaning the caller should
/// retry the failed request once; `false` if no refresher is configured
/// or the refresh failed.
async fn refresh_after_unauthorized(&self) -> bool;
/// Whether the provider holds a credential worth a real outbound attempt —
/// an unexpired token (in memory or on disk), or a static key. Default
/// `true` always attempts.
/// an unexpired token (in memory or on disk), or a static key.
fn has_usable_credential(&self) -> bool {
true
}
}
/// Static credential provider. Used by tests and by callers that pass a
/// raw `&str` token with no `AuthManager` available.
/// Non-refreshing provider for tests and for callers that pass a raw `&str`
/// token with no `AuthManager` available.
///
/// `apply()` delegates to the underlying `HttpAuth::apply()`.
/// `refresh_after_unauthorized()` always returns `false`.
///
/// `bearer` is the wire bearer the inner `HttpAuth` will send in the
/// `Authorization` header. Stored alongside the inner so `snapshot().token`
/// returns the same prefix that goes out on the wire (used by
/// 401-attribution telemetry). `None` when no bearer is configured.
/// `bearer` duplicates whatever `inner` stamps into the `Authorization`
/// header; it exists so `snapshot().token` reports the same prefix that goes
/// out on the wire, which 401-attribution telemetry relies on.
pub struct StaticAuthCredentialProvider {
inner: Box<dyn HttpAuth>,
bearer: Option<String>,
}
impl StaticAuthCredentialProvider {
/// Wrap `inner` so callers see it as an `AuthCredentialProvider`. Pass
/// the bearer token that `inner.apply()` will send in the `Authorization`
/// header so `snapshot().token` reflects the wire bearer truthfully.
/// `bearer` must be the token `inner.apply()` sends, or `snapshot()` will
/// misreport the wire credential.
pub fn new(inner: Box<dyn HttpAuth>, bearer: Option<String>) -> Self {
Self { inner, bearer }
}
@@ -1,5 +1,4 @@
//! `reqwest-middleware` layer: stamps auth headers and retries on 401.
//! Gated behind the `middleware` cargo feature.
use std::sync::Arc;
@@ -52,6 +51,8 @@ impl Middleware for AuthRetryMiddleware {
if resp.status() != StatusCode::UNAUTHORIZED || self.max_retries == 0 {
return Ok(resp);
}
// Streaming bodies do not clone, so such requests cannot be replayed
// and the 401 stands.
let Some(backup) = backup else {
return Ok(resp);
};
@@ -154,7 +155,7 @@ mod tests {
m.assert_async().await;
}
/// Simulates a real auth manager: starts with stale token, refresh swaps to fresh.
/// Starts with a stale token; refresh swaps in the fresh one.
struct SimulatedAuthManager {
token: Mutex<Option<String>>,
fresh_token: String,
+4 -4
View File
@@ -1,7 +1,7 @@
/// Apply auth headers to outbound visibility requests.
/// Implemented by `kigi-shell::util::kigi_auth_credentials::KigiAuthCredentials`
/// to keep credential construction owned by shell while letting data-collector
/// build the request without reaching back into shell types.
/// Applies auth headers to outbound visibility requests. Implemented by
/// `kigi-shell::util::kigi_auth_credentials::KigiAuthCredentials`, keeping
/// credential construction owned by shell while data-collector builds the
/// request without reaching back into shell types.
pub trait HttpAuth: Send + Sync {
fn apply(&self, builder: reqwest::RequestBuilder, base_url: &str) -> reqwest::RequestBuilder;
}
+82 -31
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!(
@@ -702,7 +714,7 @@ async fn run_agent_command(
}
}
}
// Fire-and-forget model-catalog warmup (nothing joins the handle now that
// Fire-and-forget model-catalog warmup (nothing joins the handle because
// the xAI settings fetch it used to carry is gone).
drop(kigi_shell::agent::models::start_early_prefetch(None));
kigi_shell::agent::mvp_agent::warm_async_http_client();
@@ -1221,15 +1233,34 @@ fn main() {
"Found crashed sessions from a previous run"
);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
// Capped workers: tokio's per-core default pins 100+ thread slots on
// many-core shared hosts and later spawns die with EAGAIN.
let runtime = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(kigi_tty_utils::runtime::capped_worker_threads().get())
.enable_all()
.build()
.unwrap_or_else(|e| panic!("failed to start tokio runtime: {e}"));
{
Ok(runtime) => runtime,
Err(e) => {
// 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();
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.",
);
std::process::exit(1);
}
};
let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE);
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);
}
}
@@ -1580,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),
}
}
@@ -1605,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,
@@ -1620,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)) => {
@@ -1793,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");
@@ -1966,7 +2017,7 @@ mod tests {
assert!(s.last_session_id.is_none());
}
/// An UNCONFIRMED `session/new` (leader died before its response) must not
/// be replayed — its id was never assigned — but previously loaded
/// be replayed — its id was never assigned — but earlier loaded
/// sessions still restore.
#[tokio::test]
async fn replay_after_unconfirmed_session_new_restores_prior_sessions() {
@@ -116,7 +116,7 @@ impl ChatStateActor {
/// Dispatch a command to the appropriate mutation or query handler.
fn handle_command(&mut self, cmd: ChatStateCommand) {
match cmd {
// ═══ Mutations ═══
// Mutations
ChatStateCommand::PushUserMessage { item } => {
self.push_user_message(item);
}
@@ -240,7 +240,7 @@ impl ChatStateActor {
self.repair_dangling_after_harness_halt(class);
}
// ═══ Queries ═══
// Queries
//
// Read queries are pure reads — repair only at write boundaries:
// `ChatState::new()` (startup) and `push_user_message()` (new turn).
@@ -318,7 +318,7 @@ impl ChatStateActor {
self.truncate_to_prompt_index(target_prompt_index);
self.state.turn_capture = None;
self.state.prompt_usage = None;
// `harness_trace_buffer` / `harness_trace_turns` intentionally
// `harness_trace_buffer` / `harness_trace_turns` deliberately
// survive a rewind: the goal planner / verifier subagents
// genuinely ran, so their sealed trace turns stay uploadable as
// siblings even when the live turn that triggered them is undone.
@@ -358,7 +358,7 @@ impl ChatStateActor {
let _ = reply.send(std::mem::take(&mut self.state.harness_trace_turns));
}
// ─── Narrow targeted queries ──────────────────────────────────
// Narrow targeted queries
ChatStateCommand::GetConversationLen { reply } => {
let _ = reply.send(self.get_conversation_len());
}
@@ -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);
@@ -123,7 +123,7 @@ impl ChatStateActor {
.unwrap_or_default()
}
// ─── Narrow targeted queries ─────────────────────────────────────────────
// Narrow targeted queries
/// Return the number of items in the conversation.
pub(super) fn get_conversation_len(&self) -> usize {
@@ -148,9 +148,7 @@ impl ChatStateActor {
}
}
// ============================================================================
// Pruning (standalone functions, no actor state needed)
// ============================================================================
/// Check whether pruning should run based on context utilization.
///
@@ -208,9 +206,7 @@ pub(crate) fn prune_conversation(conversation: &mut [ConversationItem], config:
}
}
// ============================================================================
// Image size-gated compaction (request-copy only)
// ============================================================================
/// Replaces an inline image evicted to keep the request body under the proxy's
/// 50 MB limit. Phrased so the model treats the image as gone rather than
@@ -376,7 +372,7 @@ fn conversation_body_bytes(conversation: &[ConversationItem]) -> usize {
/// always retain the newest images, an image only transitions image →
/// placeholder as *newer/larger* payloads push the body past the limit, never
/// placeholder → image within a stable prefix. (Token compaction removes old
/// turns wholesale and can free room to restore a previously-evicted image,
/// turns wholesale and can free room to restore an earlier-evicted image,
/// but that already rewrites the prefix and invalidates the server-side prompt
/// cache, so the restore is free.)
///
@@ -450,19 +446,17 @@ pub(crate) fn compact_images_to_byte_budget(
}
}
// ============================================================================
// Memory reminder injection
// ============================================================================
use crate::types::MEMORY_CONTEXT_OPEN_TAG;
/// Upsert a memory reminder into the conversation's system message.
///
/// If the first item is a `System` message, any previously injected memory
/// reminder section is replaced in-place; otherwise the reminder is appended.
/// If the first item is a `System` message, any existing memory reminder
/// section is replaced in-place; otherwise the reminder is appended.
/// If no system message exists, a new `System` item is prepended.
///
/// Returns `true` when the conversation was changed.
/// Returns `true` when the conversation changed.
pub(super) fn inject_memory_reminder(items: &mut Vec<ConversationItem>, reminder: &str) -> bool {
let reminder = reminder.trim();
if reminder.is_empty() {
@@ -505,9 +499,7 @@ fn upsert_memory_reminder_text(system_prompt: &mut std::sync::Arc<str>, reminder
}
}
// ============================================================================
// String helpers
// ============================================================================
fn safe_char_slice(s: &str, start: usize, count: usize) -> String {
s.chars().skip(start).take(count).collect()
@@ -529,9 +521,12 @@ mod tests {
fn should_prune_gating() {
use std::num::NonZeroU64;
let cw = NonZeroU64::new(10000).unwrap();
assert!(!should_prune(1000, cw)); // 10%
assert!(should_prune(6000, cw)); // 60%
assert!(!should_prune(5000, cw)); // 50% exact (> not >=)
// 10%
assert!(!should_prune(1000, cw));
// 60%
assert!(should_prune(6000, cw));
// 50% exact (> not >=)
assert!(!should_prune(5000, cw));
}
#[test]
@@ -558,7 +553,8 @@ mod tests {
assert!(sys.content.contains("Remember: user likes rust"));
assert!(sys.content.starts_with("You are helpful."));
}
assert_eq!(items.len(), 2); // no new item added
// no new item added
assert_eq!(items.len(), 2);
}
#[test]
@@ -569,7 +565,7 @@ mod tests {
assert!(matches!(&items[0], ConversationItem::System(_)));
}
// -- image size-gated compaction tests --
// image size-gated compaction tests
/// A user message with a small fixed inline image.
fn user_with_image(text: &str) -> ConversationItem {
@@ -661,8 +657,10 @@ mod tests {
// dropping a *batch* of the oldest, not just the one image needed to
// clear the trigger. This is the hysteresis that keeps the prefix
// cache-warm for the following turns.
let img_bytes = 1_000_000usize; // ~1 MB url each
let n = (IMAGE_COMPACT_TRIGGER_BYTES / img_bytes) + 2; // body just over trigger
// ~1 MB url each
let img_bytes = 1_000_000usize;
// body just over trigger
let n = (IMAGE_COMPACT_TRIGGER_BYTES / img_bytes) + 2;
let mut conv: Vec<ConversationItem> = (0..n)
.map(|i| user_with_image_of_bytes(&format!("i{i}"), img_bytes))
.collect();
@@ -726,7 +724,7 @@ mod tests {
assert!(has_placeholder(&conv[0]));
}
// -- conversation_body_bytes tests --
// conversation_body_bytes tests
#[test]
fn conversation_body_bytes_empty_is_json_array() {
@@ -777,7 +775,7 @@ mod tests {
assert!(conversation_body_bytes(&conv) >= IMAGE_COMPACT_TRIGGER_BYTES);
}
// -- edge cases: exactness, boundaries, ordering --
// edge cases: exactness, boundaries, ordering
#[test]
fn body_bytes_parity_multi_image_unicode_escaping() {
@@ -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
}
}
}
@@ -137,7 +135,7 @@ pub(crate) struct ChatState {
/// Opaque credential secrets (api key, optional extra auth, client version).
/// Stored opaquely — the actor never interprets them.
pub credentials: Credentials,
/// Bytes/4 estimate of tokens added since the last `record_token_usage`.
/// Bytes/4 estimate of tokens accumulated since the last `record_token_usage`.
/// Used by `check_preflight_overflow` to detect context window overflows
/// between model responses.
pub estimated_tokens_since_model: u64,
@@ -304,7 +302,8 @@ mod tests {
fn new_state_has_correct_defaults() {
let state = ChatState::new(vec![], test_sampling_config());
assert_eq!(state.prompt_index, 0);
assert_eq!(state.total_tokens, 0); // empty conversation → 0
// empty conversation → 0
assert_eq!(state.total_tokens, 0);
assert!(state.conversation.is_empty());
assert!(state.agent_edited_paths.is_empty());
assert!(state.prompt_texts.is_empty());
@@ -333,7 +332,8 @@ mod tests {
ConversationItem::tool_result("call-1", "w".repeat(4000).as_str()),
];
let state = ChatState::new(items, test_sampling_config());
assert_eq!(state.total_tokens, 4000); // 4 * (4000/4)
// 4 * (4000/4)
assert_eq!(state.total_tokens, 4000);
}
#[test]
@@ -91,9 +91,7 @@ impl TestHarness {
}
}
// ============================================================================
// Lifecycle tests
// ============================================================================
#[tokio::test]
async fn actor_spawns_and_shuts_down_via_cancellation() {
@@ -121,9 +119,7 @@ async fn actor_shuts_down_when_all_handles_dropped() {
tokio::time::sleep(Duration::from_millis(50)).await;
}
// ============================================================================
// Mutation tests
// ============================================================================
#[tokio::test]
async fn push_user_message_appends_and_persists() {
@@ -319,9 +315,10 @@ async fn estimated_tokens_tracks_tool_result_delta() {
.push_tool_result(ConversationItem::tool_result("call-1", "x".repeat(4000)));
let estimated = h.handle.get_estimated_total_tokens().await;
assert_eq!(estimated, 101_000); // 100K model-reported + 1K delta
// 100K model-reported + 1K delta
assert_eq!(estimated, 101_000);
// model-reported total_tokens is unchanged
// model-reported total_tokens is `unchanged`
let actual = h.handle.get_total_tokens().await;
assert_eq!(actual, 100_000);
}
@@ -359,7 +356,7 @@ async fn estimated_tokens_tracks_synthetic_user_message_delta() {
"expected ~1.1M tokens estimated, got {estimated}",
);
// model-reported `total_tokens` is unchanged — only the delta moved.
// model-reported `total_tokens` is `unchanged` — only the delta moved.
assert_eq!(h.handle.get_total_tokens().await, 100_000);
}
@@ -451,7 +448,7 @@ async fn replace_conversation_persists_and_emits_reset() {
h.handle.push_user_message(ConversationItem::user("b"));
// Drain the two Message records
let _ = h.handle.get_conversation().await; // sync point
let _ = h.handle.get_conversation().await;
h.drain_persistence();
let new_items = vec![ConversationItem::system("compacted")];
@@ -661,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();
@@ -721,9 +762,7 @@ async fn restore_snapshot_restores_all_fields() {
assert_eq!(tokens, 500);
}
// ============================================================================
// Query tests
// ============================================================================
#[tokio::test]
async fn get_conversation_returns_current_state() {
@@ -765,7 +804,8 @@ async fn replace_system_head_noop_when_head_matches_modulo_newline() {
ConversationItem::system("same\n"),
ConversationItem::user("hi"),
]);
let _ = h.drain_persistence(); // clear any seed writes
// clear any seed writes
let _ = h.drain_persistence();
let changed = h.handle.replace_system_head("same").await;
assert_eq!(
changed,
@@ -878,9 +918,7 @@ async fn check_auto_compact_triggers_at_threshold() {
assert_eq!(t.utilization_percent, 86);
}
// ============================================================================
// Edge-case / integration tests
// ============================================================================
#[tokio::test]
async fn record_agent_edited_path_deduplicates() {
@@ -974,19 +1012,19 @@ async fn truncate_removes_items_after_target_prompt_index() {
// Build 3 turns: system + 3x (user + assistant)
h.handle.push_user_message(ConversationItem::system("sys"));
h.handle.push_user_message(ConversationItem::user("q1"));
h.handle.increment_prompt_index(); // 1
h.handle.increment_prompt_index();
h.handle.cache_prompt_text("q1".to_string());
h.handle
.push_assistant_response(ConversationItem::assistant("a1"));
h.handle.push_user_message(ConversationItem::user("q2"));
h.handle.increment_prompt_index(); // 2
h.handle.increment_prompt_index();
h.handle.cache_prompt_text("q2".to_string());
h.handle
.push_assistant_response(ConversationItem::assistant("a2"));
h.handle.push_user_message(ConversationItem::user("q3"));
h.handle.increment_prompt_index(); // 3
h.handle.increment_prompt_index();
h.handle.cache_prompt_text("q3".to_string());
h.handle
.push_assistant_response(ConversationItem::assistant("a3"));
@@ -1000,7 +1038,8 @@ async fn truncate_removes_items_after_target_prompt_index() {
h.handle.truncate_to_prompt_index(1).await;
let conv = h.handle.get_conversation().await;
assert_eq!(conv.len(), 3); // sys + q1 + a1
// sys + q1 + a1
assert_eq!(conv.len(), 3);
let idx = h.handle.get_prompt_index().await;
assert_eq!(idx, 1);
@@ -1032,7 +1071,8 @@ async fn truncate_to_zero_keeps_only_system() {
h.handle.truncate_to_prompt_index(0).await;
let conv = h.handle.get_conversation().await;
assert_eq!(conv.len(), 1); // just "sys"
// just "sys"
assert_eq!(conv.len(), 1);
assert!(matches!(&conv[0], ConversationItem::System(_)));
assert_eq!(h.handle.get_prompt_index().await, 0);
}
@@ -1040,7 +1080,7 @@ async fn truncate_to_zero_keeps_only_system() {
#[tokio::test]
async fn truncate_is_noop_when_already_at_target() {
let mut h = TestHarness::new();
h.handle.increment_prompt_index(); // 1
h.handle.increment_prompt_index();
let _ = h.handle.get_prompt_index().await;
h.drain_events();
@@ -1056,9 +1096,7 @@ async fn truncate_is_noop_when_already_at_target() {
assert!(events.is_empty());
}
// ============================================================================
// Snapshot/restore comprehensive tests
// ============================================================================
#[tokio::test]
async fn snapshot_restore_preserves_all_fields() {
@@ -1139,9 +1177,7 @@ async fn with_initial_conversation_preserves_items() {
assert_eq!(conv.len(), 2);
}
// ============================================================================
// BuildConversationRequest tests
// ============================================================================
#[tokio::test]
async fn build_request_includes_all_messages() {
@@ -1234,7 +1270,8 @@ async fn build_request_injects_memory_when_no_system() {
.await
.unwrap();
assert_eq!(request.items.len(), 2); // new System + original User
// new System + original User
assert_eq!(request.items.len(), 2);
assert!(matches!(&request.items[0], ConversationItem::System(_)));
}
@@ -1341,11 +1378,12 @@ async fn build_request_does_not_mutate_actor_state() {
.await
.unwrap();
// Actor's own conversation should be unchanged
// Actor's own conversation should be `unchanged`
let conv = h.handle.get_conversation().await;
assert_eq!(conv.len(), 2);
if let ConversationItem::System(ref sys) = conv[0] {
assert_eq!(sys.content.as_ref(), "sys"); // no memory injected into original
// no memory injected into original
assert_eq!(sys.content.as_ref(), "sys");
}
}
@@ -1424,9 +1462,7 @@ async fn build_request_with_multiple_tool_calls_and_results() {
assert_eq!(request.items.len(), 6);
}
// ============================================================================
// Parallel tool calls with mixed accept/reject
// ============================================================================
/// Simulates the exact sequence that `kigi-shell`'s `execute_tool_calls`
/// produces when the model emits 3 parallel tool calls and:
@@ -1451,7 +1487,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
let h = TestHarness::new();
// ── Turn setup ──────────────────────────────────────────────────────
// Turn setup
// System prompt
h.handle.push_user_message(ConversationItem::system(
"You are a helpful coding assistant.",
@@ -1464,7 +1500,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
h.handle.increment_prompt_index();
// ── Model response: 3 parallel tool calls ───────────────────────────
// Model response: 3 parallel tool calls
// The model's single assistant message contains all 3 tool calls.
// In the real code, this is built from the streaming response and pushed
// via `push_assistant_response`.
@@ -1493,7 +1529,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
});
h.handle.push_assistant_response(assistant_with_tools);
// ── Tool execution results (simulating execute_tool_calls) ──────────
// Tool execution results (simulating execute_tool_calls)
// Tool #1: read_file — user accepted, tool executed successfully
h.handle.push_tool_result(ConversationItem::tool_result(
@@ -1517,7 +1553,7 @@ async fn parallel_tool_calls_accept_first_reject_second_skip_third() {
"Tool execution cancelled due to earlier permission rejection for tool `run_terminal_cmd`",
));
// ── Verify the conversation state ───────────────────────────────────
// Verify the conversation state
let conv = h.handle.get_conversation().await;
// Expected: System + User + Assistant(3 calls) + 3 ToolResults = 6 items
@@ -1731,9 +1767,7 @@ async fn parallel_tool_calls_with_rejection_persists_all_items() {
);
}
// ============================================================================
// Race condition: cancellation mid-tool-execution → dangling calls on reload
// ============================================================================
/// Simulates the race condition where:
/// 1. Model emits 3 parallel tool calls (single assistant message)
@@ -1972,9 +2006,7 @@ async fn all_tool_calls_dangling_after_crash() {
assert_eq!(request.items.len(), 6);
}
// ============================================================================
// Live-session cancellation: user cancels mid-tool-execution (no restart)
// ============================================================================
/// Simulates an in-session abort where:
/// 1. Model emits 3 parallel tool calls → assistant pushed to conversation
@@ -1984,7 +2016,7 @@ async fn all_tool_calls_dangling_after_crash() {
///
/// This is different from the reload scenario: `ChatState::new` doesn't run
/// again because the actor is still alive. The fix is that `push_user_message`
/// now calls `repair_dangling_tool_calls` before appending the new user
/// calls `repair_dangling_tool_calls` before appending the new user
/// message, so the conversation is cleaned up in-place.
#[tokio::test]
async fn live_cancel_before_any_tool_execution_repairs_on_next_user_message() {
@@ -1992,14 +2024,14 @@ async fn live_cancel_before_any_tool_execution_repairs_on_next_user_message() {
let h = TestHarness::new();
// ── Turn 1: normal conversation ─────────────────────────────────────
// Turn 1: normal conversation
h.handle
.push_user_message(ConversationItem::system("You are a helpful assistant."));
h.handle.push_user_message(ConversationItem::user("Hello"));
h.handle
.push_assistant_response(ConversationItem::assistant("Hi! How can I help?"));
// ── Turn 2: model wants 3 tool calls, user cancels immediately ──────
// Turn 2: model wants 3 tool calls, user cancels immediately
h.handle
.push_user_message(ConversationItem::user("Read, edit, and test everything"));
@@ -2023,7 +2055,7 @@ async fn live_cancel_before_any_tool_execution_repairs_on_next_user_message() {
},
]));
// *** USER CANCELS HERE (Ctrl+C) ***
// USER CANCELS HERE (Ctrl+C)
// The tokio task is aborted. execute_tool_calls never ran.
// Zero ToolResult items pushed. The conversation has dangling calls.
@@ -2127,7 +2159,7 @@ async fn live_cancel_after_partial_tool_results_repairs_remaining() {
"file contents here",
));
// *** USER CANCELS HERE — tool #2 and #3 never executed ***
// USER CANCELS HERE — tool #2 and #3 never executed
// User types a new prompt
h.handle.push_user_message(ConversationItem::user(
@@ -2178,7 +2210,6 @@ async fn live_cancel_after_partial_tool_results_repairs_remaining() {
}
// Turn message capture tests
// ============================================================================
#[tokio::test]
async fn turn_capture_collects_all_message_types() {
@@ -2537,7 +2568,6 @@ async fn turn_capture_survives_integrity_repair_prefix_shrink() {
// Capture starts after the 7-item prefix: turn_start_offset == 7.
h.handle.begin_turn_capture();
// First turn item lands while the prefix duplicates are still present.
h.handle
.push_assistant_response(ConversationItem::assistant("turn-1"));
@@ -2636,9 +2666,7 @@ async fn turn_capture_survives_persisted_memory_reminder_prepend() {
));
}
// ============================================================================
// Narrow targeted query tests
// ============================================================================
#[tokio::test]
async fn get_conversation_len_empty() {
@@ -2795,7 +2823,7 @@ async fn get_conversation_item_at_does_not_mutate_state() {
assert_eq!(conv.len(), 2);
}
// ── Multimodal regression tests for get_first_user_text() ────────────────────
// Multimodal regression tests for get_first_user_text()
/// Confirms that `get_first_user_text()` returns `None` when the first content
/// part of the first user message is an image (not text). This preserves the
@@ -2805,7 +2833,6 @@ async fn get_first_user_text_image_first_returns_none() {
use kigi_sampling_types::{ContentPart, UserItem};
let h = TestHarness::new();
// First message: image-only user message (no text part)
h.handle.push_user_message(ConversationItem::User(UserItem {
content: vec![ContentPart::Image {
url: "data:image/png;base64,abc".into(),
@@ -2865,7 +2892,7 @@ async fn get_first_user_text_text_then_image_returns_text() {
assert_eq!(text.as_deref(), Some("look at this"));
}
// ── Tests for GetLastUserQueryText, GetConversationCounts, GetSystemMessage ───
// Tests for GetLastUserQueryText, GetConversationCounts, GetSystemMessage
#[tokio::test]
async fn get_last_user_query_text_empty_conversation() {
@@ -2935,18 +2962,17 @@ async fn get_system_message_returns_first_system() {
assert!(matches!(sys, ConversationItem::System(s) if s.content.as_ref() == "You are helpful."));
}
// ============================================================================
// Subagent bootstrap regression tests
//
// These verify that `replace_conversation` correctly syncs the system prompt
// into a ChatStateActor that was spawned before the prompt was built — the
// exact sequence used by `spawn_session_actor` for subagents.
// ============================================================================
#[tokio::test]
async fn fresh_subagent_bootstrap_has_system_message_after_replace() {
// Simulate a fresh (non-forked) subagent: actor starts with an empty conversation.
let h = TestHarness::new(); // spawns with vec![]
// spawns with vec![]
let h = TestHarness::new();
// At this point the actor has no system message, mirroring the bug.
assert!(h.handle.get_system_message().await.is_none());
@@ -3005,9 +3031,7 @@ async fn forked_subagent_bootstrap_replaces_parent_system_message() {
assert_eq!(conv.len(), 3);
}
// ============================================================================
// In-memory retained pruning tests (PR3)
// ============================================================================
/// Helper: push N complete turns (user + assistant + tool-result) so the
/// conversation grows to a predictable length.
@@ -3165,8 +3189,10 @@ async fn prune_retained_bounds_long_session_footprint() {
use crate::persistence::MockChatPersistence;
use crate::types::PruningConfig;
const TURNS: usize = 50; // enough turns to clear many old tool results
const CONTENT_LEN: usize = 50_000; // 50 KB per tool result
// enough turns to clear many old tool results
const TURNS: usize = 50;
// 50 KB per tool result
const CONTENT_LEN: usize = 50_000;
const PLACEHOLDER_LEN: usize = "[Tool result omitted — too old]".len();
let (mock, _rx) = MockChatPersistence::new();
@@ -3324,7 +3350,8 @@ async fn prune_retained_synthetic_user_does_not_advance_age() {
// Three real turns, each with a large tool result.
for i in 0..3usize {
handle.push_user_message(ConversationItem::user(format!("real q{i}")));
handle.increment_prompt_index(); // prompt_index = i+1
// prompt_index = i+1
handle.increment_prompt_index();
handle.push_assistant_response(ConversationItem::assistant(format!("a{i}")));
handle.push_tool_result(ConversationItem::tool_result(
format!("call_{i}"),
@@ -3339,7 +3366,8 @@ async fn prune_retained_synthetic_user_does_not_advance_age() {
// Fourth real turn starts: prompt_index → 4, pruning fires inside push_user_message.
handle.push_user_message(ConversationItem::user("real q3"));
handle.increment_prompt_index(); // prompt_index = 4
// prompt_index = 4
handle.increment_prompt_index();
// Sync
let conv = handle.get_conversation().await;
@@ -3616,7 +3644,6 @@ async fn context_window_downgrade_triggers_auto_compact() {
"api_backend must not change"
);
// Now auto-compact sees the 128k window and fires
let trigger = h.handle.check_auto_compact_needed(85).await;
assert!(
trigger.is_some(),
@@ -3633,14 +3660,13 @@ async fn context_window_downgrade_triggers_auto_compact() {
);
}
// ============================================================================
// KV Cache Prefix Stability Tests
//
// These test `build_conversation_request()` output prefix stability through
// the full pipeline -- pruning, memory injection, image pruning, snapshot
// restore. Prefix stability within a compaction epoch is the invariant that
// keeps the inference engine's prefix / KV cache hitting. The sibling-Reasoning refactor
// deleted the placeholder/splice machinery these tests previously had to work
// deleted the placeholder/splice machinery these tests earlier had to work
// around.
//
// These target the refactored sibling-Reasoning shape:
@@ -3649,7 +3675,6 @@ async fn context_window_downgrade_triggers_auto_compact() {
// - Reasoning lives as `ConversationItem::Reasoning(rs::ReasoningItem)`
// siblings; the From<&ConversationRequest> for rs::CreateResponse impl
// emits them inline in `input` order.
// ============================================================================
/// Serialize a ConversationRequest using only the public
/// `From<&ConversationRequest> for rs::CreateResponse` trait impl.
@@ -4049,8 +4074,6 @@ async fn prefix_stable_after_image_pruning() {
// Image stripping mutates the old user turn's content, so full
// byte-level prefix stability cannot hold at that item. We verify:
// 1. System prompt preserved
// 2. Items grew
// 3. Text items appear in the same relative order
let body1 = serialize_via_public_api(&req1);
let body2 = serialize_via_public_api(&req2);
@@ -4164,7 +4187,8 @@ async fn prefix_stable_after_tool_result_pruning() {
h.handle
.push_tool_result(ConversationItem::tool_result("c2", "y".repeat(500)));
h.handle.push_user_message(ConversationItem::user("q3"));
h.handle.record_token_usage(6000); // > 50% of 10k context
// > 50% of 10k context
h.handle.record_token_usage(6000);
let req2 = h
.handle
@@ -4319,9 +4343,7 @@ async fn prefix_stable_after_session_resume() {
);
}
// ============================================================================
// Out-of-band history repair (kigi/session/repair)
// ============================================================================
/// Bricked-session shape: an orphaned tool result survives load (the eager
/// repairs only fix dangling calls) and 400s on every request. The
@@ -37,7 +37,7 @@ impl std::error::Error for RepairHistoryBlocked {}
/// Commands sent to the ChatStateActor via mpsc channel.
pub enum ChatStateCommand {
// ═══ Mutations (fire-and-forget) ═══
// Mutations (fire-and-forget)
/// Push a user message into the conversation.
PushUserMessage { item: ConversationItem },
@@ -64,7 +64,7 @@ pub enum ChatStateCommand {
RecordTokenUsage { total_tokens: u64 },
/// Stash the per-turn `TokenUsage` from the most recent model response.
/// Overwrites any previously stashed value.
/// Overwrites any earlier stashed value.
RecordLastTurnUsage { usage: TokenUsage },
RecordModelCallUsage {
@@ -176,7 +176,7 @@ pub enum ChatStateCommand {
/// Repair dangling tool calls after a harness-initiated halt.
RepairDanglingAfterHarnessHalt { class: &'static str },
// ═══ Queries (request/response via oneshot) ═══
// Queries (request/response via oneshot)
/// Build a ConversationRequest ready to send to the API.
/// Clones the conversation, prunes old tool results, repairs dangling
/// tool calls, injects memory reminder, and assembles the request.
@@ -280,7 +280,7 @@ pub enum ChatStateCommand {
reply: oneshot::Sender<Vec<Vec<ConversationItem>>>,
},
// ═══ Narrow targeted queries (avoid full-conversation clone) ═══
// Narrow targeted queries (avoid full-conversation clone)
/// Get the number of items in the conversation.
/// Cheaper than `GetConversation` when only the length is needed.
GetConversationLen { reply: oneshot::Sender<usize> },
@@ -32,7 +32,7 @@ impl CompactionMode {
}
}
/// Replace the detail level if this is `Segments`, else unchanged. Lets the
/// Replace the detail level if this is `Segments`, else `unchanged`. Lets the
/// resolver attach the separately-resolved `KIGI_COMPACTION_DETAIL`.
pub fn with_segment_detail(self, detail: CompactionDetail) -> Self {
match self {
@@ -77,7 +77,7 @@ pub const INDEX_HEADER: &str = "# Compaction Segment Index\n\n\
| Segment | File | Turns | Approx bytes | Keywords |\n\
|---|---|---|---|---|\n";
/// Zero-padded segment number, e.g. `007`. The single source of the pad width.
/// Zero-`padded` segment number, e.g. `007`. The single source of the pad width.
fn segment_label(index: u64) -> String {
format!("{index:03}")
}
@@ -724,7 +724,7 @@ mod tests {
assert_eq!(classify_compaction_path("compaction/notes.md"), None);
}
// --- Parity with the Python implementation's own test vectors (compaction_utils_test.py) ---
// Parity with the Python implementation's own test vectors (compaction_utils_test.py)
/// Keyword extraction: the Python `TestExtractKeywords` vectors (bare `8.`
/// headers, stopword filtering, dedup, no-section-8 fallback) plus our
@@ -299,7 +299,7 @@ pub fn extract_last_user_query(conversation: &[ConversationItem]) -> Option<Stri
.map(|item| extract_user_query(&item.text_content()))
.filter(|q| !q.is_empty())
}
/// The continuation prompt added to the conversation after auto-compaction.
/// The continuation prompt appended to the conversation after auto-compaction.
///
/// Stored here (rather than only in `kigi-shell`) so that query-extraction
/// helpers in this crate can recognise and exclude it from "real user prompt"
@@ -666,7 +666,7 @@ pub fn format_compact_summary(summary: &str) -> String {
/// A markdown "**Analysis**"-style header has no opening `<analysis>` tag for
/// step 1 to catch; it ends at an orphan `</analysis>`. Everything up to and
/// including the *last* `</analysis>` is dropped, so a scratchpad that itself
/// quotes `</analysis>` mid-reasoning is still removed whole. The peel is
/// quotes `</analysis>` mid-reasoning is still stripped whole. The peel is
/// skipped when the block already starts with a numbered section — including a
/// markdown-decorated one like `## 1.` or `**1.**` — so a `</analysis>` merely
/// echoed inside a real section never truncates the summary. Any leftover
+3 -6
View File
@@ -34,7 +34,7 @@ impl ChatStateHandle {
Self { cmd_tx }
}
// ═══ Fire-and-forget mutations ═══
// Fire-and-forget mutations
/// Push a user message into the conversation.
pub fn push_user_message(&self, item: ConversationItem) {
@@ -238,7 +238,6 @@ impl ChatStateHandle {
.send(ChatStateCommand::UpdateCredentials { credentials });
}
/// Restore from a snapshot.
pub fn restore_snapshot(&self, snapshot: ChatStateSnapshot) {
let _ = self
.cmd_tx
@@ -280,7 +279,7 @@ impl ChatStateHandle {
.send(ChatStateCommand::RepairDanglingAfterHarnessHalt { class });
}
// ═══ Async queries (via oneshot) ═══
// Async queries (via oneshot)
/// Send a query to the actor and await the reply.
///
@@ -419,7 +418,6 @@ impl ChatStateHandle {
.unwrap_or(0)
}
/// Get sampling config.
pub async fn get_sampling_config(&self) -> Option<SamplingConfig> {
self.query("GetSamplingConfig", |reply| {
ChatStateCommand::GetSamplingConfig { reply }
@@ -501,7 +499,6 @@ impl ChatStateHandle {
.unwrap_or_default()
}
/// Check if auto-compact is needed.
pub async fn check_auto_compact_needed(
&self,
threshold_percent: u8,
@@ -516,7 +513,7 @@ impl ChatStateHandle {
.flatten()
}
// ═══ Narrow targeted queries ═══
// Narrow targeted queries
/// Get the number of items in the conversation.
///
+2 -4
View File
@@ -1,8 +1,7 @@
//! kigi-chat-state — Actor-based chat state management for xAI agents.
//!
//! This crate extracts conversation state management from `kigi-shell`'s
//! `acp_session.rs` into a standalone actor. It follows the same actor pattern
//! as `kigi-hunk-tracker`:
//! Holds the conversation state driven by `kigi-shell`'s `acp_session.rs`,
//! following the same actor pattern as `kigi-hunk-tracker`:
//!
//! ```text
//! ┌────────────────┐ ┌──────────────────────────────────────┐
@@ -35,7 +34,6 @@ pub mod persistence;
pub mod types;
pub mod usage;
// Re-export main types for convenience
pub use actor::ChatStateActor;
pub use actor::state::{
estimate_conversation_tokens, estimate_item_tokens, estimate_messages_tokens,
@@ -27,9 +27,7 @@ pub trait ChatPersistence: Send + 'static {
fn flush(&mut self);
}
// ============================================================================
// Mock (test double) — channel-based, no locks, no atomics
// ============================================================================
/// A record of a persistence call, sent over a channel to the test.
#[derive(Debug, Clone)]
@@ -101,9 +99,7 @@ impl ChatPersistence for MockChatPersistence {
}
}
// ============================================================================
// Null (noop) — for benchmarks / scenarios where persistence is unwanted
// ============================================================================
/// No-op implementation: discards everything (for benchmarks / noop scenarios).
pub struct NullChatPersistence;
+11 -28
View File
@@ -13,54 +13,46 @@ use serde::{Deserialize, Serialize};
/// an injected block.
pub const MEMORY_CONTEXT_OPEN_TAG: &str = "<memory-context>";
/// Closing tag paired with [`MEMORY_CONTEXT_OPEN_TAG`].
pub const MEMORY_CONTEXT_CLOSE_TAG: &str = "</memory-context>";
/// Configuration for the ChatStateActor at spawn time.
#[derive(Debug, Clone)]
pub struct ChatStateConfig {
/// Initial conversation items to populate the state with.
pub initial_conversation: Vec<ConversationItem>,
/// Sampling configuration (model, context window, etc.).
pub sampling_config: SamplingConfig,
}
/// Immutable snapshot of the actor's state (for forking, rewind).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatStateSnapshot {
/// The full conversation history.
pub conversation: Vec<ConversationItem>,
/// Current sampling configuration.
pub sampling_config: SamplingConfig,
/// Current prompt index (incremented per user turn).
/// Incremented per user turn.
pub prompt_index: usize,
/// Accumulated token usage.
pub total_tokens: u64,
/// Bytes/4 estimate of the conversation as of the last `record_token_usage`.
/// `0` means unknown (pre-field snapshot); restore re-estimates instead.
/// `0` means unknown (snapshot written without the field); restore
/// re-estimates instead.
#[serde(default)]
pub estimate_at_last_response: u64,
/// File paths the agent has edited.
pub agent_edited_paths: BTreeSet<String>,
/// Cached prompt texts for rewind preview.
/// Cached for rewind preview.
pub prompt_texts: Vec<String>,
/// Timestamp when the current stream started (epoch ms).
/// Epoch ms.
pub stream_start_ms: Option<i64>,
/// Timestamp when the current turn started (epoch ms).
/// Epoch ms.
pub turn_start_ms: Option<i64>,
/// Prompt index at which the last compaction occurred.
pub last_compaction_prompt_index: Option<usize>,
/// Opaque credential secrets (API key, optional extra auth, client version).
#[serde(default)]
pub credentials: Credentials,
}
/// Metadata for session notifications (timing info).
/// Timing metadata for session notifications.
#[derive(Debug, Clone)]
pub struct NotificationMeta {
/// Timestamp when the current stream started (epoch ms).
/// Epoch ms.
pub stream_start_ms: Option<i64>,
/// Timestamp when the current turn started (epoch ms).
/// Epoch ms.
pub turn_start_ms: Option<i64>,
}
@@ -70,7 +62,6 @@ pub struct NotificationMeta {
/// Two modes: soft trim (keep head + tail) and hard clear (replace entirely).
#[derive(Debug, Clone)]
pub struct PruningConfig {
/// Whether pruning is enabled.
pub enabled: bool,
/// Number of recent turns whose tool results are never pruned.
pub keep_last_n_turns: usize,
@@ -116,9 +107,7 @@ pub enum AuthType {
/// The actor just stores and returns them — it never interprets them.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Credentials {
/// API key for authentication.
pub api_key: Option<String>,
/// Whether this is a session token (refreshable) or user-provided api key.
#[serde(default)]
pub auth_type: AuthType,
/// Optional extra auth material forwarded with requests when present.
@@ -130,7 +119,7 @@ pub struct Credentials {
/// Produced by `TakeTurnMessages` after a `BeginTurnCapture`/message-push cycle.
#[derive(Debug, Clone)]
pub struct TurnCapture {
/// The ordered sequence of messages appended during this turn.
/// In the order they were appended.
pub messages: Vec<ConversationItem>,
/// Whether compaction (conversation replacement) occurred mid-turn.
pub compaction_occurred: bool,
@@ -142,24 +131,18 @@ pub struct TurnCapture {
/// when only role counts and total length are needed (e.g. for telemetry).
#[derive(Debug, Clone, Default)]
pub struct ConversationCounts {
/// Total number of items in the conversation.
pub total: usize,
/// Number of `User` items.
pub user: usize,
/// Number of `Assistant` items.
pub assistant: usize,
/// Number of `ToolResult` items.
pub tool_result: usize,
}
/// Info returned when auto-compact threshold is exceeded.
#[derive(Debug, Clone)]
pub struct AutoCompactTrigger {
/// Current total token count.
pub total_tokens: u64,
/// Model's context window size.
pub context_window: NonZeroU64,
/// Current utilization as a percentage (0–100).
/// 0–100.
pub utilization_percent: u8,
}
@@ -45,7 +45,6 @@ fn main() {
println!("git2 (index only): {} files in {:?}", files.len(), elapsed);
}
_ => {
// Run all three methods multiple times for comparison
println!("Benchmarking file listing for: {}", root_path.display());
println!();
@@ -56,7 +55,6 @@ fn main() {
let _ = collect_files_git2(root_path, &registry);
let _ = collect_files_git2_index_only(root_path, &registry);
// CLI benchmark
let mut cli_times = Vec::with_capacity(iterations);
let mut cli_count = 0;
for _ in 0..iterations {
@@ -66,7 +64,6 @@ fn main() {
cli_count = files.len();
}
// git2 benchmark (with untracked)
let mut git2_times = Vec::with_capacity(iterations);
let mut git2_count = 0;
for _ in 0..iterations {
@@ -76,7 +73,6 @@ fn main() {
git2_count = files.len();
}
// git2 index-only benchmark
let mut git2_index_times = Vec::with_capacity(iterations);
let mut git2_index_count = 0;
for _ in 0..iterations {
@@ -86,7 +82,6 @@ fn main() {
git2_index_count = files.len();
}
// Print results
let cli_avg = cli_times.iter().sum::<std::time::Duration>() / iterations as u32;
let git2_avg = git2_times.iter().sum::<std::time::Duration>() / iterations as u32;
let git2_index_avg =
@@ -117,9 +112,7 @@ fn main() {
}
}
/// Collect files using git CLI (original approach)
fn collect_files_cli(root_path: &Path, registry: &LanguageRegistry) -> Vec<std::path::PathBuf> {
// Get tracked files
let tracked_output = Command::new("git")
.args(["ls-files"])
.current_dir(root_path)
@@ -130,7 +123,6 @@ fn collect_files_cli(root_path: &Path, registry: &LanguageRegistry) -> Vec<std::
_ => return vec![],
};
// Get untracked files
let untracked_output = Command::new("git")
.args(["ls-files", "--others", "--exclude-standard"])
.current_dir(root_path)
@@ -159,7 +151,6 @@ fn collect_files_cli(root_path: &Path, registry: &LanguageRegistry) -> Vec<std::
files
}
/// Collect files using git2 (new approach)
fn collect_files_git2(root_path: &Path, registry: &LanguageRegistry) -> Vec<std::path::PathBuf> {
let repo = match Repository::open(root_path) {
Ok(r) => r,
@@ -183,7 +174,6 @@ fn collect_files_git2(root_path: &Path, registry: &LanguageRegistry) -> Vec<std:
})
.collect();
// Get untracked files
let mut status_opts = StatusOptions::new();
status_opts
.include_untracked(true)
@@ -204,7 +194,6 @@ fn collect_files_git2(root_path: &Path, registry: &LanguageRegistry) -> Vec<std:
files
}
/// Collect files using git2 index only (tracked files only, no untracked)
fn collect_files_git2_index_only(
root_path: &Path,
registry: &LanguageRegistry,
@@ -21,7 +21,6 @@ fn main() {
std::process::exit(1);
};
// First, verify all queries compile
println!("Verifying query compilation...");
let registry = LanguageRegistry::new();
for ext in &["ts", "tsx", "js", "jsx", "rs", "go", "py"] {
@@ -176,14 +176,12 @@ fn main() {
}
}
/// Get the effective cache path - use custom if provided, otherwise default.
fn effective_cache_path(repo_path: &Path, custom_cache: Option<&Path>) -> PathBuf {
custom_cache
.map(|p| p.to_path_buf())
.unwrap_or_else(|| get_cache_path(repo_path))
}
/// Load index from cache or build if necessary.
fn load_or_build_index(repo_path: &Path, cache_path: &Path) -> ScopeGraphIndex {
if let Ok(index) = load_index(cache_path) {
println!("Loaded index from cache: {}", cache_path.display());
@@ -204,7 +202,6 @@ fn load_or_build_index(repo_path: &Path, cache_path: &Path) -> ScopeGraphIndex {
files, defs, refs, elapsed
);
// Save to cache
if let Err(e) = save_index(cache_path, &index) {
println!("Warning: Failed to save cache: {}", e);
} else {
@@ -260,7 +257,6 @@ fn cmd_definition(
let navigator = Navigator::new(index);
let result = match (file, row, col, symbol) {
// Position-based lookup
(Some(file_path), Some(r), Some(c), _) => {
let abs_path = if file_path.is_absolute() {
file_path
@@ -276,7 +272,6 @@ fn cmd_definition(
}
}
}
// Symbol-based lookup
(_, _, _, Some(sym)) => navigator.goto_definition_by_name(&sym, None),
_ => {
println!("Error: Must provide either --file, --row, --col OR --symbol");
@@ -310,7 +305,6 @@ fn cmd_references(
let navigator = Navigator::new(index);
let result = match (file, row, col, symbol) {
// Position-based lookup
(Some(file_path), Some(r), Some(c), _) => {
let abs_path = if file_path.is_absolute() {
file_path
@@ -326,7 +320,6 @@ fn cmd_references(
}
}
}
// Symbol-based lookup
(_, _, _, Some(sym)) => navigator.goto_references_by_name(&sym, None, include_definition),
_ => {
println!("Error: Must provide either --file, --row, --col OR --symbol");
@@ -361,7 +354,6 @@ fn cmd_stats(path: &Path, custom_cache: Option<&Path>) {
println!(" References: {}", refs);
println!(" Aliases: {}", index.alias_count());
// Top symbols by reference count
let ref_counts = index.top_referenced_symbols(10);
println!("\nTop 10 most referenced symbols:");
@@ -149,7 +149,6 @@ pub enum IndexCommand {
BackgroundRefresh {
/// Files that need reindexing (stale or new)
stale_files: Vec<String>,
/// Files that were deleted
deleted_files: Vec<String>,
},
/// Get the number of indexed files (lightweight, no clone)
@@ -330,7 +329,7 @@ impl IndexManagerHandle {
self.command_tx.send(IndexCommand::Shutdown)
}
// ========== Async Query APIs ==========
// Async Query APIs
/// Go to definition at the given position (async).
///
@@ -417,7 +416,7 @@ impl IndexManagerHandle {
Ok(rx.await.expect("IndexManager dropped before responding"))
}
// ========== Blocking Query APIs ==========
// Blocking Query APIs
/// Go to definition at the given position (blocking).
pub fn goto_definition_blocking(
@@ -518,7 +517,6 @@ impl IndexManagerConfig {
}
}
/// Set the cache path.
pub fn with_cache_path(mut self, path: PathBuf) -> Self {
self.cache_path = Some(path);
self
@@ -1349,12 +1347,12 @@ fn background_index_refresh(
if cached_meta.is_stale(path_ref) {
// Check if file exists or is deleted
if path_ref.exists() {
Some((Some(path.clone()), None)) // Stale
Some((Some(path.clone()), None))
} else {
Some((None, Some(path.clone()))) // Deleted
Some((None, Some(path.clone())))
}
} else {
None // Up to date
None
}
})
.fold(
@@ -1391,8 +1389,10 @@ fn background_index_refresh(
let registry = crate::languages::LanguageRegistry::new();
let new_files: Vec<String> = ignore::WalkBuilder::new(&root_path)
.hidden(true) // Skip hidden files/dirs
.git_ignore(true) // Respect .gitignore
// Skip hidden files/dirs
.hidden(true)
// Respect .gitignore
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build()
@@ -1530,7 +1530,7 @@ impl CoalescedEvents {
fn add(&mut self, event: FileEvent) {
// Renames are special: they carry two paths. Process the "to" path
// as Created (it needs indexing) and the "from" as Removed.
// as `Created` (it needs indexing) and the "from" as `Removed`.
if event.kind == FileEventKind::Renamed && event.paths.len() >= 2 {
self.insert(event.paths[0].clone(), FileEventKind::Removed);
self.insert(event.paths[1].clone(), FileEventKind::Created);
@@ -1551,11 +1551,11 @@ impl CoalescedEvents {
Entry::Occupied(mut e) => {
let prev = *e.get();
match (prev, kind) {
// Created/Modified then Removed → cancel both
// `Created`/`Modified` then `Removed` → cancel both
(FileEventKind::Created | FileEventKind::Modified, FileEventKind::Removed) => {
e.remove();
}
// Removed then Created/Modified → file replaced, treat as Created
// `Removed` then `Created`/`Modified` → file replaced, treat as `Created`
(FileEventKind::Removed, FileEventKind::Created | FileEventKind::Modified) => {
e.insert(FileEventKind::Created);
}
@@ -1649,8 +1649,10 @@ fn is_identifier_like(node: &tree_sitter::Node<'_>) -> bool {
|| kind == "field_identifier"
|| kind == "shorthand_property_identifier"
|| kind == "shorthand_property_identifier_pattern"
|| kind == "attribute" // Python
|| kind == "package_identifier" // Go
// Python
|| kind == "attribute"
// Go
|| kind == "package_identifier"
}
#[cfg(test)]
@@ -1830,7 +1832,8 @@ mod tests {
let dir = tempdir().unwrap();
let file_path = dir.path().join("huge.rs");
// Write a file larger than MAX_INDEXABLE_FILE_SIZE
let content = "fn a() {}\n".repeat(600_000); // ~6MB
// ~6MB
let content = "fn a() {}\n".repeat(600_000);
fs::write(&file_path, &content).unwrap();
let config = IndexManagerConfig::new(dir.path().to_path_buf())
@@ -1891,7 +1894,8 @@ mod tests {
fs::write(dir.path().join("binary.rs"), &binary).unwrap();
// Oversized file — should be skipped
let big = "fn big() {}\n".repeat(500_000); // ~6MB
// ~6MB
let big = "fn big() {}\n".repeat(500_000);
fs::write(dir.path().join("huge.rs"), &big).unwrap();
let index = IndexBuilder::new().build(dir.path()).unwrap();
@@ -1930,12 +1934,13 @@ mod tests {
let stats = handle.get_stats().unwrap();
assert_eq!(stats.files, 1);
assert!(stats.definitions >= 2); // hello + world
// hello + world
assert!(stats.definitions >= 2);
handle.shutdown().unwrap();
}
// ========== CoalescedEvents tests ==========
// CoalescedEvents tests
#[test]
fn test_coalesce_create_then_remove_cancels() {
@@ -2003,7 +2008,7 @@ mod tests {
let mut c = CoalescedEvents::new();
c.add(FileEvent::renamed("/a.rs".into(), "/b.rs".into()));
c.add(FileEvent::removed("/b.rs".into()));
// /a.rs should still be Removed, /b.rs Created+Removed = cancelled
// /a.rs should still be `Removed`, /b.rs `Created`+`Removed` = cancelled
assert_eq!(c.events.len(), 1);
assert_eq!(c.events[&PathBuf::from("/a.rs")], FileEventKind::Removed);
}
@@ -2013,7 +2018,7 @@ mod tests {
let mut c = CoalescedEvents::new();
c.add(FileEvent::renamed("/a.rs".into(), "/b.rs".into()));
c.add(FileEvent::modified("/b.rs".into()));
// /a.rs Removed, /b.rs Created+Modified → Modified (last writer wins)
// /a.rs `Removed`, /b.rs `Created`+`Modified` → `Modified` (last writer wins)
assert_eq!(c.events.len(), 2);
assert_eq!(c.events[&PathBuf::from("/a.rs")], FileEventKind::Removed);
assert_eq!(c.events[&PathBuf::from("/b.rs")], FileEventKind::Modified);
@@ -52,7 +52,6 @@ impl StringId {
Self(id)
}
/// Get the raw u32 value.
#[inline]
pub const fn as_u32(self) -> u32 {
self.0
@@ -209,7 +208,6 @@ impl StringInterner {
self.offsets.is_empty()
}
/// Total bytes used by the arena.
#[inline]
pub fn arena_bytes(&self) -> usize {
self.arena.len()
@@ -271,7 +269,7 @@ impl StringInterner {
///
/// After a bulk build the arena and offsets Vecs may hold up to 2× their
/// actual content due to doubling growth. Calling this reclaims that
/// wasted heap. The lookup table is intentionally left unshrunk because
/// wasted heap. The lookup table is deliberately left unshrunk because
/// it benefits from load-factor headroom.
///
/// This is an internal maintenance hook called by `ScopeGraphIndex::compact()`.
@@ -313,7 +311,7 @@ mod tests {
let id1 = interner.intern("src");
let id2 = interner.intern("lib");
let id3 = interner.intern("src"); // duplicate
let id3 = interner.intern("src");
assert_eq!(id1, id3);
assert_ne!(id1, id2);
@@ -348,7 +346,8 @@ mod tests {
// Invalid UTF-8
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
let id2 = interner.intern_bytes(invalid_utf8);
assert_eq!(interner.get(id2), None); // Not valid UTF-8
// Not valid UTF-8
assert_eq!(interner.get(id2), None);
assert_eq!(interner.get_bytes(id2), Some(invalid_utf8));
// Duplicate bytes return same ID
@@ -1,5 +1,3 @@
//! JavaScript/JSX language configuration.
use crate::languages::types::TSLanguageConfig;
pub fn js_lang() -> TSLanguageConfig {
@@ -114,7 +114,7 @@ impl LanguageRegistry {
/// Compute a hash of all tree-sitter queries across all languages.
///
/// This is used to detect when queries change, which should trigger
/// a rebuild of the index even if file contents haven't changed.
/// a rebuild of the index even if file contents are `unchanged`.
///
/// The hash is computed by:
/// 1. Sorting languages by their primary ID for deterministic ordering
@@ -1,5 +1,3 @@
//! Python language configuration.
use crate::languages::types::TSLanguageConfig;
pub fn python_lang() -> TSLanguageConfig {
@@ -12,7 +10,6 @@ pub fn python_lang() -> TSLanguageConfig {
"variable".to_owned(),
"module".to_owned(),
]],
// Python definitions query
r#"
; Class definitions
(class_definition
@@ -19,7 +19,6 @@ pub fn ts_lang() -> TSLanguageConfig {
"const".to_owned(),
"let".to_owned(),
]],
// Comprehensive TypeScript query with full type coverage
r#"
;; === DEFINITIONS ===
@@ -30,7 +30,6 @@ impl TSLanguageConfig {
}
}
/// Get the language IDs.
pub fn language_ids(&self) -> &[String] {
&self.language_ids
}
@@ -43,17 +42,14 @@ impl TSLanguageConfig {
.unwrap_or("unknown")
}
/// Get the file extensions.
pub fn file_extensions(&self) -> &[String] {
&self.file_extensions
}
/// Get the namespaces.
pub fn namespaces(&self) -> &[Vec<String>] {
&self.namespaces
}
/// Get the file definition queries.
pub fn file_definition_queries(&self) -> &str {
&self.file_definition_queries
}
@@ -235,7 +235,8 @@ impl IndexBuilder {
.git_ignore(self.respect_gitignore)
.git_global(self.respect_gitignore)
.git_exclude(self.respect_gitignore)
.threads(self.num_threads.min(12)) // Use parallel walking (capped at 12)
// Use parallel walking (capped at 12)
.threads(self.num_threads.min(12))
.build_parallel();
walker.run(|| {
@@ -309,7 +310,6 @@ impl IndexBuilder {
//
// New approach: for each batch of build_batch_size files:
// 1. Parse in parallel (par_chunks preserves thread-local cache locality)
// 2. Merge the batch into the index
// 3. Drop the batch before starting the next one
// Peak = O(build_batch_size) symbols + growing index simultaneously.
for batch in file_paths.chunks(build_batch_size) {
@@ -1,25 +1,21 @@
//! Index caching for fast loading.
//!
//! Uses a custom binary format with magic bytes "SGIX" for the new interned format.
//! Automatically detects and skips legacy bincode format (returns error so caller can rebuild).
//! The on-disk format is a custom binary layout tagged with the magic bytes
//! "SGIX". Caches written by the earlier bincode format are detected and
//! rejected rather than parsed, so the caller rebuilds from source.
use std::path::Path;
use crate::scope_graph::ScopeGraphIndex;
/// Default cache file name.
pub const CACHE_FILE_NAME: &str = ".goto_index.bin";
/// Error type for cache operations.
#[derive(Debug)]
pub enum CacheError {
/// IO error.
IoError(std::io::Error),
/// Serialization error.
SerializeError(String),
/// Deserialization error.
DeserializeError(String),
/// Legacy format detected (caller should rebuild).
/// A bincode-era cache was found; the caller is expected to rebuild.
LegacyFormat,
}
@@ -42,19 +38,14 @@ impl From<std::io::Error> for CacheError {
}
}
/// Result type for cache operations.
pub type Result<T> = std::result::Result<T, CacheError>;
/// Get the default cache path for a repository.
pub fn get_cache_path(root_path: &Path) -> std::path::PathBuf {
root_path.join(CACHE_FILE_NAME)
}
/// Load an index from cache.
///
/// Uses the new binary format with magic bytes "SGIX".
/// Returns `CacheError::LegacyFormat` if the file uses the old bincode format,
/// signaling to the caller that a rebuild is needed.
/// Returns `CacheError::LegacyFormat` for a bincode-format cache, signaling to
/// the caller that a rebuild is needed.
pub fn load_index(cache_path: &Path) -> Result<ScopeGraphIndex> {
if !cache_path.exists() {
return Err(CacheError::IoError(std::io::Error::new(
@@ -63,11 +54,10 @@ pub fn load_index(cache_path: &Path) -> Result<ScopeGraphIndex> {
)));
}
// Use ScopeGraphIndex::load which handles format detection
match ScopeGraphIndex::load(cache_path) {
Ok(Some(index)) => Ok(index),
// `Ok(None)` is how the loader reports a legacy-format file.
Ok(None) => {
// None means legacy format was detected
tracing::info!(
cache_path = %cache_path.display(),
"Legacy cache format detected, will rebuild"
@@ -78,15 +68,12 @@ pub fn load_index(cache_path: &Path) -> Result<ScopeGraphIndex> {
}
}
/// Save an index to cache using the new binary format.
pub fn save_index(cache_path: &Path, index: &ScopeGraphIndex) -> Result<()> {
index.save(cache_path).map_err(CacheError::IoError)
}
/// Save an index to cache asynchronously (in a background thread).
///
/// Returns immediately and spawns a thread to do the actual saving.
/// Useful for saving the index without blocking the main thread.
/// Saves on a detached thread: the caller gets no join handle and no result,
/// so a failed write is only visible in the logs.
pub fn save_index_async(cache_path: std::path::PathBuf, index: ScopeGraphIndex) {
std::thread::spawn(move || {
if let Err(e) = save_index(&cache_path, &index) {
@@ -95,12 +82,11 @@ pub fn save_index_async(cache_path: std::path::PathBuf, index: ScopeGraphIndex)
});
}
/// Check if a cache exists and return its metadata.
pub fn cache_exists(cache_path: &Path) -> bool {
cache_path.exists()
}
/// Get cache file size in bytes.
/// Size of the cache file in bytes, or `None` if it cannot be stat'd.
pub fn cache_size(cache_path: &Path) -> Option<u64> {
std::fs::metadata(cache_path).ok().map(|m| m.len())
}
@@ -52,7 +52,8 @@ impl IndexOperation {
/// Whether this operation requires exclusive access.
pub fn is_exclusive(&self) -> bool {
match self {
Self::Load => false, // Shared/read access
// Shared/read access
Self::Load => false,
Self::Save | Self::Build | Self::BackgroundRefresh => true,
}
}
@@ -77,8 +78,10 @@ impl std::fmt::Display for IndexOperation {
/// In-memory lock state for same-process deduplication.
struct InMemoryLockState {
operation: IndexOperation,
readers: usize, // Count for shared locks
exclusive: bool, // Whether an exclusive lock is held
// Count for shared locks
readers: usize,
// Whether an exclusive lock is held
exclusive: bool,
}
/// Global registry of in-memory locks (same process).
@@ -299,7 +302,6 @@ fn try_acquire_in_memory_lock(workspace: &Path, operation: IndexOperation) -> bo
true
}
/// Release an in-memory lock.
fn release_in_memory_lock(workspace: &Path, operation: IndexOperation) {
// Use entry API for atomic check-and-modify
if let dashmap::mapref::entry::Entry::Occupied(mut entry) =
@@ -439,7 +441,6 @@ mod tests {
// Drop first lock
drop(guard1);
// Now second should succeed
let guard3 = try_lock(workspace, IndexOperation::Build);
assert!(guard3.is_acquired());
}
@@ -490,7 +491,6 @@ mod tests {
// Drop shared lock
drop(guard1);
// Now exclusive should succeed
let guard3 = try_lock(workspace, IndexOperation::Build);
assert!(guard3.is_acquired());
}
@@ -1,4 +1,4 @@
//! Index management: building, caching, locking, and updating.
//! Index management: building, caching, and workspace locking.
mod builder;
pub mod cache;
@@ -238,7 +238,7 @@ impl Navigator {
///
/// # Arguments
/// * `file_path` - Path to the file
/// * `row` - 1-indexed line number
/// * `row` - 1-indexed line number
/// * `col` - 1-indexed column number
/// * `include_definition` - Whether to include the definition location in results
///
@@ -388,8 +388,8 @@ fn is_identifier_like(node: &tree_sitter::Node<'_>) -> bool {
| "field_identifier"
| "shorthand_property_identifier"
| "shorthand_property_identifier_pattern"
| "attribute" // Python
| "package_identifier" // Go
| "attribute"
| "package_identifier"
)
}
@@ -2,21 +2,21 @@
use serde::{Deserialize, Serialize};
/// Describes the relation between two nodes in the ScopeGraph.
/// Edge weight in the ScopeGraph. Every variant is directed source-to-target,
/// in the order its name reads.
#[derive(Serialize, Deserialize, PartialEq, Eq, Copy, Clone, Debug)]
pub enum EdgeKind {
/// The edge weight from a nested scope to its parent scope.
/// Nested scope to its parent scope.
ScopeToScope,
/// The edge weight from a definition to its definition scope.
/// Definition to the scope that owns it, which for a hoisted def is the
/// parent of the scope it was written in.
DefToScope,
/// The edge weight from an import to its definition scope.
/// Import to its defining scope.
ImportToScope,
/// The edge weight from a reference to its definition.
RefToDef,
/// The edge weight from a reference to its import.
RefToImport,
}
@@ -44,7 +44,7 @@ pub type ExtractedSymbols = (
/// even if file contents haven't changed.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum QueryVersion {
/// Legacy format - index was built before query versioning was added.
/// Legacy format - index was built without query versioning.
/// This triggers a rebuild since we don't know what queries were used.
/// Default for backwards compatibility with old cached indexes.
#[default]
@@ -394,7 +394,6 @@ impl ScopeGraph {
})
}
/// Find all references to a given name
pub fn find_references(&self, name: &str, src: &[u8]) -> Vec<Range> {
self.graph
.node_indices()
@@ -703,9 +702,7 @@ impl ScopeGraphIndex {
}
}
// ========================================================================
// String interning helpers
// ========================================================================
/// Intern a string and return its ID.
#[inline]
@@ -725,9 +722,7 @@ impl ScopeGraphIndex {
self.interner.get_id(s)
}
// ========================================================================
// File metadata operations
// ========================================================================
/// Update file metadata (size and mtime) for staleness tracking.
pub fn update_file_meta(&mut self, path: &Path) {
@@ -751,9 +746,7 @@ impl ScopeGraphIndex {
}
}
// ========================================================================
// Alias operations
// ========================================================================
/// Register an alias relationship: alias_name is an alias for original_name
pub fn add_alias(&mut self, alias_name: &str, original_name: &str) {
@@ -771,9 +764,7 @@ impl ScopeGraphIndex {
self.add_alias(&alias_name, &original_name);
}
// ========================================================================
// Symbol insertion (for builder/manager use)
// ========================================================================
/// Add a definition occurrence for a symbol.
pub fn add_definition(&mut self, symbol: &str, path: &str, line: usize) {
@@ -847,9 +838,7 @@ impl ScopeGraphIndex {
.filter_map(|(&id, meta)| self.get_str(id).map(|path| (path, meta)))
}
// ========================================================================
// File operations
// ========================================================================
/// Add a file's scope graph to the index
pub fn add_file(&mut self, file_path: PathBuf, graph: ScopeGraph, src: &[u8]) {
@@ -996,9 +985,7 @@ impl ScopeGraphIndex {
self.file_meta.len()
}
// ========================================================================
// Query operations
// ========================================================================
/// Find where a symbol is defined (includes resolving aliases)
pub fn find_definitions(&self, symbol: &str) -> Vec<(&str, usize)> {
@@ -1284,9 +1271,7 @@ impl ScopeGraphIndex {
.collect()
}
// ========================================================================
// Statistics and metadata
// ========================================================================
/// Get statistics: (files_count, total_definitions, total_references).
///
@@ -1301,7 +1286,6 @@ impl ScopeGraphIndex {
)
}
/// Get alias count
pub fn alias_count(&self) -> usize {
self.aliases.len()
}
@@ -1356,9 +1340,7 @@ impl ScopeGraphIndex {
self.interner.shrink_to_fit();
}
// ========================================================================
// Binary serialization (custom format with magic bytes)
// ========================================================================
/// Save the index to a file in binary format.
pub fn save(&self, path: &Path) -> io::Result<()> {
@@ -1617,7 +1599,8 @@ impl ScopeGraphIndex {
Ok(Self {
interner,
graphs: HashMap::new(), // Not serialized
// Not serialized
graphs: HashMap::new(),
definitions,
references,
aliases,
@@ -1716,7 +1699,8 @@ mod tests {
index.compact();
let (f1, d1, r1) = index.stats();
index.compact(); // second call must be a no-op
// second call must be a no-op
index.compact();
let (f2, d2, r2) = index.stats();
assert_eq!(f1, f2);
@@ -16,17 +16,12 @@ pub use nodes::{LocalDef, LocalImport, LocalScope, NodeKind, Reference, Symbol,
use crate::languages::TSLanguageConfig;
/// Result of building a scope graph, including alias pairs.
pub struct ScopeGraphResult {
/// The scope graph for the file.
pub graph: ScopeGraph,
/// Alias pairs: (alias_name, original_name).
/// Each pair is `(alias_name, original_name)`.
pub aliases: Vec<(String, String)>,
}
/// Build a ScopeGraph from tree-sitter query and source.
///
/// This is a convenience wrapper around `scope_graph_from_definitions_query`.
pub fn build_scope_graph(
query: &tree_sitter::Query,
root_node: tree_sitter::Node<'_>,
@@ -89,7 +89,6 @@ impl LocalDef {
&src[self.range.start_byte()..self.range.end_byte()]
}
/// Get the scope range.
pub fn scope_range(&self) -> &Range {
&self.scope.range
}
@@ -25,7 +25,6 @@ pub enum FileEvent {
/// A file was renamed/moved.
Renamed {
/// Original path.
from: PathBuf,
/// New path.
to: PathBuf,
@@ -49,7 +48,8 @@ impl FileEvent {
FileEvent::Created { .. } => true,
FileEvent::Modified { .. } => true,
FileEvent::Deleted { .. } => false,
FileEvent::Renamed { .. } => false, // Only path update needed
// Only path update needed
FileEvent::Renamed { .. } => false,
}
}
@@ -49,7 +49,6 @@ impl Location {
}
}
/// Get the file path.
pub fn file_path(&self) -> &PathBuf {
&self.file_path
}
@@ -113,7 +113,8 @@ impl FileMeta {
let current = Self::from_metadata(&meta);
*self != current
}
Err(_) => true, // File deleted or inaccessible
// File deleted or inaccessible
Err(_) => true,
}
}
}
@@ -70,7 +70,6 @@ impl Position {
self.character
}
/// Get the byte offset.
pub fn byte_offset(&self) -> usize {
self.byte_offset
}
@@ -80,7 +79,6 @@ impl Position {
self.byte_offset
}
/// Set the byte offset.
pub fn set_byte_offset(&mut self, byte_offset: usize) {
self.byte_offset = byte_offset;
}
@@ -120,7 +118,6 @@ impl Position {
}
}
/// Move to the next line.
pub fn move_to_next_line(mut self) -> Self {
self.line += 1;
self.character = 0;
@@ -188,12 +185,10 @@ impl Range {
Self::for_tree_node(node)
}
/// Get the start position.
pub fn start_position(&self) -> Position {
self.start_position
}
/// Get the end position.
pub fn end_position(&self) -> Position {
self.end_position
}
@@ -208,12 +203,10 @@ impl Range {
&self.end_position
}
/// Set the start position.
pub fn set_start_position(&mut self, position: Position) {
self.start_position = position;
}
/// Set the end position.
pub fn set_end_position(&mut self, position: Position) {
self.end_position = position;
}
@@ -1,29 +1,20 @@
//! Isolated RSS test for incremental reindexing.
//!
//! This test lives in its own integration-test file (and therefore its own
//! Bazel `rust_test` target / process) so that its whole-process RSS samples
//! are not polluted by the other allocation-heavy tests in
//! `memory_integration.rs` (e.g. `test_fresh_build_rss`,
//! `test_build_batch_peak_rss_is_bounded`, `test_compact_reduces_rss_vs_uncompacted`).
//! `libtest` runs a test binary's tests concurrently across `num_cpus` threads,
//! but VmRSS is measured per-*process*. Sharing a binary with the other
//! allocation-heavy tests in `memory_integration.rs` made this test observe
//! their allocator churn, intermittently pushing the measured incremental
//! growth delta over the 20 MB budget on aarch64 fastbuild CI (~31 MB).
//!
//! Background: `libtest` runs tests in a single binary concurrently across
//! `num_cpus` threads, and VmRSS is measured per-*process*. When this test
//! ran inside `memory_integration.rs` it observed allocator churn from the
//! other tests on the same process, intermittently pushing the measured
//! "incremental growth" delta over the 20 MB budget on aarch64 fastbuild CI
//! (`run_1_of_2` and `run_2_of_2` both failed at ~31 MB).
//!
//! Keep this file to a single test. If you need to add another RSS-sensitive
//! test, give it its own file too rather than reintroducing the
//! noisy-neighbor problem.
//! Hence its own integration-test file, and therefore its own Bazel
//! `rust_test` target and process. Keep this file to a single test; any other
//! RSS-sensitive test needs a file of its own rather than a noisy neighbor.
use kigi_codebase_graph::{FileEvent, IndexManager, IndexManagerConfig};
use std::fs;
use std::path::Path;
use tempfile::tempdir;
/// Read current process RSS in bytes. Supports Linux and macOS.
/// Returns `None` on unsupported platforms.
fn rss_bytes() -> Option<usize> {
#[cfg(target_os = "linux")]
{
@@ -65,7 +56,6 @@ fn fmt_rss(rss: Option<f64>) -> String {
rss.map_or("N/A".to_string(), |v| format!("{:.1}MB", v))
}
/// Create N Rust source files in `dir`, each with `defs_per_file` function defs.
fn create_rust_files(dir: &Path, count: usize, defs_per_file: usize) {
for i in 0..count {
let mut content = String::new();
@@ -122,7 +112,6 @@ fn test_bulk_incremental_indexing_memory() {
);
println!("RSS after incremental: {}", fmt_rss(rss_after_incremental));
// Incremental reindexing should not grow memory significantly.
if let (Some(after_inc), Some(after_build)) = (rss_after_incremental, rss_after_build) {
let growth = after_inc - after_build;
assert!(
@@ -80,9 +80,7 @@ fn create_binary_files(dir: &Path, count: usize, size: usize) {
}
}
// =========================================================================
// Tests
// =========================================================================
#[test]
#[serial_test::serial]
@@ -221,11 +219,14 @@ fn test_builder_skips_binary_and_oversized_in_bulk() {
let root = dir.path();
// Mix of valid, binary, and oversized files
create_rust_files(root, 100, 5); // 100 valid files
create_binary_files(root, 50, 10_000); // 50 binary files
// 100 valid files
create_rust_files(root, 100, 5);
// 50 binary files
create_binary_files(root, 50, 10_000);
// One oversized file
let big = "fn x() {}\n".repeat(600_000); // ~6MB
// ~6MB
let big = "fn x() {}\n".repeat(600_000);
fs::write(root.join("oversized.rs"), &big).unwrap();
drop(big);
@@ -234,7 +235,8 @@ fn test_builder_skips_binary_and_oversized_in_bulk() {
// Only the 100 valid files should be indexed
assert_eq!(files, 100);
assert!(defs >= 500); // 100 files × 5 defs
// 100 files × 5 defs
assert!(defs >= 500);
}
/// Measure RSS growth from a single `get_snapshot()` call on a representative index.
@@ -248,7 +250,8 @@ fn test_builder_skips_binary_and_oversized_in_bulk() {
fn test_single_snapshot_rss() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 500, 10); // 500 files, 5 000 defs
// 500 files, 5 000 defs
create_rust_files(root, 500, 10);
let config = IndexManagerConfig::new(root.to_path_buf())
.without_cache_load()
@@ -353,7 +356,8 @@ fn test_repeated_snapshots_rss_bounded() {
fn test_fresh_build_rss() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 500, 10); // 500 files, 5 000 defs
// 500 files, 5 000 defs
create_rust_files(root, 500, 10);
let rss_before = rss_mb();
@@ -451,7 +455,8 @@ fn test_cache_load_rss() {
fn test_build_batch_size_produces_correct_index() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 200, 5); // 200 files, 1 000 defs
// 200 files, 1 000 defs
create_rust_files(root, 200, 5);
// Build with a very small batch size (10 files per merge batch)
let batched = IndexBuilder::new()
@@ -587,9 +592,7 @@ fn test_build_batch_peak_rss_is_bounded() {
assert_eq!(b_refs, u_refs, "reference count must match");
}
// =============================================================================
// Structural compaction tests
// =============================================================================
/// Verify that an index survives a save/load round-trip after compact().
///
@@ -601,7 +604,8 @@ fn test_build_batch_peak_rss_is_bounded() {
fn test_compact_then_save_load_roundtrip() {
let dir = tempdir().unwrap();
let root = dir.path();
create_rust_files(root, 50, 4); // 50 files, 200 defs
// 50 files, 200 defs
create_rust_files(root, 50, 4);
// build() calls compact() internally via build_fast()
let original = IndexBuilder::new().build(root).unwrap();
@@ -1,5 +1,8 @@
//! Config-value resolution leaf types and per-model laziness config,
//! extracted from kigi-shell for dependency inversion.
//! Config-value resolution leaf types and per-model laziness config.
//!
//! They live outside kigi-shell so crates below it (kigi-memory,
//! kigi-shared, kigi-workspace) can share them without depending on the
//! shell.
use kigi_config::env_bool;
@@ -18,7 +21,6 @@ pub enum ConfigSource {
Default,
}
/// A resolved config value with its source for diagnostics.
#[derive(Debug, Clone)]
pub struct Resolved<T> {
pub value: T,
@@ -156,9 +158,8 @@ pub struct LazinessDetectorPerModelConfig {
pub min_confidence: Option<f32>,
/// When `Some(true)` (or `None` — the default), the classifier sees
/// the assistant's plain-text reasoning as `[assistant reasoning]`
/// lines. `Some(false)` drops them (the pre-2026-05 behavior).
/// `None` defers to the harness default (`LAZINESS_INCLUDE_REASONING`,
/// currently `true`).
/// lines; `Some(false)` drops them. `None` defers to the harness
/// default (`LAZINESS_INCLUDE_REASONING`, currently `true`).
#[serde(default)]
pub include_reasoning: Option<bool>,
}
@@ -668,6 +668,11 @@ pub struct RemoteSettings {
/// remote settings verbatim-input flag; env (`KIGI_COMPACTION_VERBATIM_INPUT`) and config override it. `None` = default (true).
#[serde(default)]
pub compaction_verbatim_input: Option<bool>,
/// Compaction summarizer `tool_choice` (`auto` | `none`) from remote
/// settings. Env (`KIGI_COMPACTION_TOOL_CHOICE`) and config override it.
/// Some backends reject `tool_choice: "none"` with tools attached.
#[serde(default)]
pub compaction_tool_choice: Option<String>,
/// Master switch for jemalloc heap sampling + threshold dumps.
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
#[serde(default)]
+3 -11
View File
@@ -1,5 +1,4 @@
//! MCP server configuration value types, extracted from kigi-shell
//! (config dependency inversion).
//! MCP server configuration value types.
use agent_client_protocol as acp;
use indexmap::IndexMap;
@@ -8,14 +7,10 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// serde default helper. Kept module-local rather than shared — the `pool`
/// module keeps its own copy for `PoolConfig`.
fn default_true() -> bool {
true
}
/// Read an MCP OAuth client secret from the named env var. Moved here with
/// `McpServerConfig` (its only caller).
fn resolve_oauth_client_secret(env_var: Option<&String>) -> Option<String> {
let env_var = env_var?;
match std::env::var(env_var) {
@@ -56,10 +51,8 @@ pub enum McpServerTransportConfig {
/// OAuth client ID for providers that don't support Dynamic Client Registration.
#[serde(default, skip_serializing_if = "Option::is_none")]
oauth_client_id: Option<String>,
/// Name of the env var holding the OAuth client secret (for BYO credentials).
#[serde(default, skip_serializing_if = "Option::is_none")]
oauth_client_secret_env_var: Option<String>,
/// OAuth scopes to request during authorization.
#[serde(default, skip_serializing_if = "Option::is_none")]
oauth_scopes: Option<Vec<String>>,
},
@@ -176,7 +169,6 @@ impl McpServerConfig {
})
.unwrap_or_default();
// Add bearer token from environment variable if specified
if let Some(env_var) = bearer_token_env_var {
match std::env::var(env_var) {
Ok(token) => {
@@ -213,7 +205,7 @@ impl McpServerConfig {
}
}
/// Extract OAuth configuration for this server, if any OAuth fields are set.
/// Inline `oauth_*` transport fields take precedence over the `oauth` block.
pub fn oauth_config(&self) -> Option<McpOAuthConfig> {
if let McpServerTransportConfig::StreamableHttp {
oauth_client_id,
@@ -260,7 +252,7 @@ pub struct RelaySyncConfig {
}
impl RelaySyncConfig {
/// Check if relay sync is enabled. Env var takes precedence over config.
/// `KIGI_RELAY_SYNC_ENABLED` overrides the configured value.
pub fn is_enabled(&self) -> bool {
if let Ok(env_val) = std::env::var("KIGI_RELAY_SYNC_ENABLED") {
return env_val.eq_ignore_ascii_case("true") || env_val == "1";
@@ -450,7 +450,8 @@ mod tests {
fn effective_half_life_converts_legacy_recency_decay() {
let mut s = MemorySearchConfig::default();
s.temporal_decay.enabled = false;
s.recency_decay = 0.5; // non-default → converted
// non-default → converted
s.recency_decay = 0.5;
let hl = s.effective_half_life_days().unwrap();
assert!(
(hl - 1.0).abs() < 1e-9,
@@ -32,7 +32,7 @@ pub enum PatternMode {
/// Action to take when rule matches.
///
/// CWE-1188: Default changed from Allow to Deny so that omitting the
/// CWE-1188: the default is Deny rather than Allow, so that omitting the
/// `action` field in a TOML permission rule does not silently create a
/// catch-all allow rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+9 -14
View File
@@ -1,5 +1,4 @@
//! Worktree-pool configuration value type, extracted from kigi-shell
//! (config dependency inversion).
//! Worktree-pool configuration value type.
use serde::{Deserialize, Serialize};
@@ -18,27 +17,23 @@ use serde::{Deserialize, Serialize};
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
/// Whether the pool is enabled at all.
/// Can be set to false to disable pooling regardless of repo size.
/// Default: true (auto-detect based on file_count_threshold)
/// When false, pooling is off regardless of repo size; otherwise
/// `file_count_threshold` decides.
#[serde(default = "default_true")]
pub enabled: bool,
/// Number of worktrees to keep ready in the pool.
/// 2 is the minimum useful value when forks need parallel worktrees.
/// Default: 2
/// Number of worktrees to keep ready. 2 is the minimum useful value when
/// forks need parallel worktrees.
#[serde(default = "default_pool_size")]
pub pool_size: usize,
/// Minimum number of tracked files for the pool to activate.
/// Below this threshold, on-demand creation is fast enough.
/// Default: 50_000
/// Minimum number of tracked files for the pool to activate. Below this,
/// on-demand creation is fast enough.
#[serde(default = "default_file_count_threshold")]
pub file_count_threshold: usize,
/// Number of threads to use for worktree creation when populating the pool.
/// This can speed up pool population on large repos, but also increases resource usage.
/// Default: 3.
/// Threads used to populate the pool. Higher values speed up population on
/// large repos at the cost of more concurrent resource use.
#[serde(default = "default_pool_parallelism")]
pub parallelism: usize,
}
Loaded 100 of 1584 files, more files were not shown because too many files have changed in this diff. Show more