reconstruct_full_config and the subagent spawn resolved both from a disk reload
that never holds fetched entries, so a fetched Messages model (anthropic,
minimax) went out with Authorization: Bearer instead of x-api-key.
[platforms.<name>] with base_url and api (openai or anthropic) declares a provider.
Its key lives in auth.json under the name or in the table; /models is fetched per
provider, entries are keyed <name>/<model>, and the key is stamped in memory only.
The Codex backend (client_version 0.156.0) served seven list+supported
models on 2026-09-22: gpt-6-astra, gpt-6-sol, gpt-6-luna, then
gpt-5.6-sol/terra/luna and gpt-5.5. Sol and Luna carry context 272000,
efforts low..max, default medium. POST /codex/responses with kigi's codex
headers answers 200 and completes for both.
The Codex backend served five `visibility=list` + `supported_in_api`
models on 2026-09-21: `gpt-6-astra` ahead of `gpt-5.6-sol/terra/luna`
and `gpt-5.5`. Astra carries context 272000 and efforts
low/medium/high/xhigh/max, default medium; the `ultra` tier it also
advertises has no kigi level and is left out. `gpt-reserve` and
`codex-auto-review` stay hidden.
Inference checked against the live backend: `POST
/backend-api/codex/responses` with `model: gpt-6-astra` and the codex
`User-Agent` kigi sends answers 200 and streams, so the model works on
the compiled-in path.
Reverts c61977c. The live listing needed no codex CLI on the user's
machine — kigi issued the request itself and `client_version` was a
compiled-in string — but the project keeps the catalog in the binary, so
the fetch path, the `OpenaiCodex` listing dialect, its parser and the
pinned client version are gone again, and the codex `User-Agent` returns
to its previous value.
The models.dev provider rename `kimi-for-coding` → `kimi-code-plan-cn`
rode in the same commit and stays: the regenerated snapshot ships under
the new id.
`xai` and `openai` carry `restrict_to_enriched`, so a live listing id the
enrichment catalog does not know is dropped. The bundled snapshot knew
neither `grok-4.7` nor `gpt-6-astra`, which left both invisible offline
and until the 24h models.dev refresh landed.
Regeneration also tripped the generator's fail-fast: models.dev renamed
the Kimi coding provider. `kimi-for-coding` is now `kimi-code-plan-cn`
(api.kimi.com — kigi's endpoint), with `kimi-code-plan-global` the
api.kimi.ai twin; the model set is unchanged. Both registry rows, the
generator target and the two snapshot lookups follow the new id, and the
cache `keep_set` guard invalidates any cache filtered under the old one.
The ChatGPT Codex catalog was compiled in: four models read once from the
Codex CLI cache. The backend moved on — `gpt-6-astra` shipped, and
`gpt-5.3-codex-spark` left — so the picker showed a table that no longer
matched the account.
`GET https://chatgpt.com/backend-api/codex/models?client_version=<v>`
serves that table. Verified against a live ChatGPT account: the OAuth
Bearer alone returns 200, the query is required (400 without it), and the
backend gates the catalog on it — `0.104.0` answers with
`codex-auto-review` alone, `0.150.0` omits `gpt-6-astra`, `0.155.1`
serves the full set. `CODEX_CLIENT_VERSION` pins the current upstream
release (openai/codex rust-v0.155.1) and the codex `User-Agent` now
derives from it, so the two can never drift.
`ListingDialect::OpenaiCodex` + `parse_openai_codex_listing` keep the
`visibility == "list" && supported_in_api` models and carry the wire
context window and per-model reasoning levels; `hardcoded_catalog` and
its short-circuit in the fetch path are gone. The catalog advertises an
`ultra` tier kigi has no level for — `think_efforts_to_options` drops it
with a warning, as it does any unknown token.
`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.
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.
`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.
`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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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>
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.
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.
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.
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.
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.
`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.
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).
C1 (decision): custom/BYOK ChatCompletions entries defaulted to the Kimi
dialect, leaking Kimi-specific body mutations (thinking:{…} control,
replayed reasoning_content, schema rewrites) to arbitrary third-party
OpenAI-compatible servers. The default is now Passthrough (vanilla
OpenAI semantics), with ONE exception mirroring Pi's base-url quirk
sniffing: entries pointed at the house/Kimi coding endpoint keep the
Kimi dialect. Registry platforms are unaffected (all declare
explicitly).
C2 (Pi mistral-conversations normalizer): Mistral requires tool-call ids
of EXACTLY nine [a-zA-Z0-9] chars; even same-session synthesized UUIDs
violate it. New ChatCompat::Mistral = StrictOpenAi behavior + the
normalizer — strip non-alphanumerics, keep exact-9 ids, else FNV-1a →
base36 (build-stable, deterministic across requests for prefix-cache
stability) with collision retry; ONE map covers tool_calls[].id and
tool_call_id so pairing survives. The mistral registry row and the
persisted 'mistral' serde value both resolve to it (pre-rename Mistral
sessions gain the contract automatically); Cerebras/NVIDIA stay on
StrictOpenAi untouched.
Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
Cross-provider audit R5 + R2-residual: BackendToolCall items round-trip
as their typed shapes with provider-issued ids (grok x_search
CustomToolCall, web_search/code_interpreter calls) and Reasoning items
carry model-bound encrypted payloads — replaying either to a DIFFERENT
Responses target names undeclared tools / undecryptable material → 400.
New transform_items_for_responses pre-pass: each [Reasoning|
BackendToolCall]* Assistant run carries provenance in
AssistantItem.model_id; on confirmed mismatch with the request's target
model, Reasoning siblings are dropped and BackendToolCall demoted to the
same text summary the Messages/ChatCompletions builders already emit.
Same-model and unknown-provenance turns stay byte-verbatim (KV-cache
prefix stability preserved). The legacy-upgrade round-trip test now
models the same-model continuation it always described.
Part 5 of the cross-provider replay audit.
Verified: sampling-types 290 + downstream 5794 green, clippy clean.
Root cause of 400 {"detail":"System messages are not allowed"} at
chatgpt.com/backend-api/codex/responses (both platforms): the codex
adaptation covered only IDENTITY HEADERS (originator/OpenAI-Beta/UA/
chatgpt-account-id) — the BODY still carried the system prompt as
role:system input items, which the codex backend rejects outright. Its
system channel is the top-level field, and stateless
(store:false) reasoning replay requires
include:["reasoning.encrypted_content"] — both per the same reference
the headers were ported from (official Codex CLI + Pi's
api/openai-codex-responses.ts).
New adapt_body_for_codex_backend (kigi-sampling-types): hoists every
system input item into (order preserved, appended to any
existing instructions; string and parts content shapes) and requests
encrypted reasoning. Idempotent. Applied at both Responses send paths,
openai_codex-GATED — the API-key path stays byte-identical
(pinned by a control wire test).
Tests: adapter unit tests (hoist+include, no-system no-op, idempotence)
plus two mock-server wire tests (codex body has no system role,
instructions + include present; plain Responses body unchanged).
Verified: sampling-types + sampler + chat-state + shell 6076 tests
green, clippy clean.
Root cause of 'Invalid signature in thinking block' (400 at
messages.1.content.0, Claude models): build_messages_request replayed
EVERY stored Reasoning item as a thinking block with no origin check —
history synthesized by other backends (encrypted_content: None → the
mandatory signature field serialized as ""), Responses-API tco_* blobs
(signature bytes, no text), and blocks signed by a DIFFERENT model after
a mid-session /model switch. Anthropic validates every replayed
signature (model-bound), so such histories 400 deterministically. The
platform split was circumstantial: Windows sessions started on the
default model and switched to Claude; macOS sessions were Claude-native
from turn 1. Adversarially verified — no platform-divergent byte path
exists in capture, storage, or replay.
New prune_replayed_thinking pass (Pi/Claude Code replay policy): keep
exactly the final assistant message's thinking when its tool loop is
still open (request ends on the tool results — an open loop can never
span a model switch) and the block is genuinely signed; strip every
other thinking block (the API ignores valid prior-turn thinking and
rejects invalid). Assistant messages emptied by the strip (thinking-only
aborted turns) are removed — empty content arrays are rejected too.
Tests: three unit tests pin strip-outside-loop (unsigned, tco_*, stale
signed), keep-in-active-loop (verbatim text+signature at content.0), and
emptied-message removal; the legacy-upgrade integration test now proves
both wire fidelity in the active loop AND stripping once the loop
closes.
Verified: sampling-types + sampler + chat-state + shell 6072 tests
green, clippy clean.
The README always shipped graph engineering enabled; the enablement was
delegated to installer env plumbing that diverged per platform:
install.sh exported KIGI_GRAPH=1 into shell rc (worked), install.ps1
wrote the User registry variable — which running Windows terminals (and
new tabs of an open Windows Terminal) never pick up, so /graph was
'missing on Windows' despite a successful install.
With resolve_graph() defaulting true in the binary (e53a66d), the env
writes are redundant complexity: drop them from both installers, keep
KIGI_GRAPH=0 as the documented opt-out (env still beats the default),
and correct the flag comment to tell this story instead of a
'gray release' one. Both scripts syntax-checked (sh -n / pwsh parser).
Installers are served from main (raw.githubusercontent), so this takes
effect for all new installs immediately — no retag needed; the running
v0.1.5 build already carries the binary-default fix.
resolve_graph() read only the KIGI_GRAPH env var with default(false)
(plan.md G0 gate), so /graph existed solely on machines whose environment
exported the dev flag — which presented as '/graph is missing on
Windows'. There was never any platform-conditional code: the Mac worked
because the dev env var was set there.
Default is now true (matching /goal's shipped state); KIGI_GRAPH=0
remains the off-switch, and availability still requires the goal harness
(BuiltinGate::Graph). AGENTS.md updated; new test pins fresh-install-on
plus env-zero-off.
Verified: kigi-shell 5262 + kigi-tui 6874 tests green, clippy clean.
A single HTTP 502 from the GitHub release CDN killed the entire v0.1.5
tag build (one job of five, ~50 minutes wasted). The build-script
download now retries twice with backoff on 5xx/429/network errors;
genuine failures (404, offline) still fail fast with the
KIGI_SHELL_BUNDLE_RG_PATH hint, and every retry prints a cargo:warning
so flakiness stays observable.
Root cause of 'model+effort switch works on Mac, not on Windows': the
switch APPLIES in-session (the dispatch/apply chain is platform-identical,
verified adversarially) but its persistence never sticks on Windows.
Every tmp+rename atomic write except auth/storage.rs committed with a
bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a
sharing violation whenever AV/search-indexer/cloud-sync transiently holds
the destination open. Consequences: [models].default never persisted
(next launch = original model), the session summary's current model never
persisted (resume = original model), and the models cache went silently
stale (all its write errors were swallowed).
- New kigi_shell_base::util::fs::replace_file — THE commit step for
tmp+rename: plain rename on Unix; on Windows delete-first + two short
backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on
failure, error always returned. Windows branch type-checked against
x86_64-pc-windows-msvc.
- Adopted at every replace site: config.toml (save_config /
atomic_write_string / mcp saves), models cache (plus unique tmp
suffixes and tracing::warn on failure — writes were fully silent),
session storage (summary/current-model, jsonl, plan/signals/
announcement/goal/graph state), auth.json, active-sessions registry,
prompt history, claude/kimi import, campaigns state, goal artifacts.
Directory-move renames (worktree pool, corrupt-file backups) keep
plain rename — their destinations don't pre-exist.
Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean,
msvc-target check of the new cfg(windows) code clean.
The shell stamps meta.provider (platform display name via
parse_managed_model_key) on every managed {platform}/{model} catalog
entry in to_acp_model_info; user-defined [model.*] entries stay
provider-less. The /model dropdown surfaces it in the description column
(a model's own description still wins), so the picker reads as 'choose a
model from your connected providers'.
Verified: kigi-shell 5261 + kigi-tui 6873 tests green, clippy clean.
Root cause of 'connected Claude, still shows Kimi / unknown model': every
fetch-plan decision consulted only the primary (Kimi) session and API keys
— never the stored subscription-OAuth sessions:
- on_auth_changed's wipe guard: a claude-pro-max-only login satisfied
'no session, no keys' → catalog wiped, early return BEFORE the fetch
and BEFORE notify_models_updated. The TUI kept an empty picker and the
prompt bar rendered 'unknown'. Guard decision extracted into the pure
should_wipe_catalog_on_auth_change (matrix-tested); a stored OAuth
session now vetoes the wipe, so the fetch runs, the first real catalog
reselects the default model (first entry = the connected provider's),
and kigi/models/update reaches the client.
- Startup prefetch: the arming gate ignored stored OAuth sessions and the
prefetch thread passed an empty token map — a claude-only user booted
onto the bundled Kimi table until a later refresh. The gate now takes
has_stored_oauth and the thread resolves each stored session's bearer
(refresh-on-expiry) via a current-thread runtime.
- Cache origins: from_config's startup cache load and cache_origin()
computed the fetch-plan origin with an empty token map, so a
claude-inclusive cached catalog never matched at startup. Both now use
presence-only stubs (stored_oauth_token_stubs — names only, no bearers)
proven equal to the real-token origin by test.
New probes in models_fetch: stored_oauth_platforms / stored_oauth_token_
stubs (sync auth.json scope scan; no AuthManager, no secrets).
Verified: kigi-shell 5260 tests green, clippy clean.
/login previously fired the resolved method's flow immediately — there was
no way to see providers or their status. It now lands on the provider
picker; already-connected providers show a green 'connected' badge in the
key column.
Shell: initialize() probes stored credentials once (primary session flag,
auth.json oauth/<provider> scopes, resolved platform keys env>auth.json>
config) and stamps _meta.connected on each advertised method
(connected_method_ids + stamp_connected_meta, pure and unit-tested).
Display state only — never an authorization input.
TUI:
- PendingMenuItem::connected() reads the badge from method meta; the
picker renders it green (accent_success), replacing the shortcut hint.
- New Action::OpenLoginPicker: /login shows the picker; mid-session it
stashes the view like dispatch_login, and starts no flow by itself.
- Mid-session picker: last row reads 'Cancel' and dispatches CancelLogin
(clicking it must not exit the app); Esc also returns to the session.
- After a successful login, the just-authenticated method is stamped
connected in the TUI's advertised-methods copy (auth_in_flight_method →
AuthComplete), so a later /login shows the badge without re-initialize.
Verified: kigi-shell 5256 + kigi-tui 6870 tests green, clippy clean.