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 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
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 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
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
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
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
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
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
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 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
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
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
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
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>
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>
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>
`/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>
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>
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.
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).
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.
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.
`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.
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.
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.
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.
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.
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.
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).
Since v0.1.7 — chat-completions dialect correctness (Pi-referenced):
- fix(chat): BYOK/custom endpoints default to Passthrough (vanilla
OpenAI semantics; Kimi body mutations no longer leak to third-party
servers); house/Kimi coding endpoints keep the Kimi dialect via
base-url detection
- fix(chat): dedicated Mistral dialect — exactly-nine-alphanumeric
tool-call id normalizer (deterministic, call/result-symmetric)
- fix(chat): tool-result images relocate to a batched user message
(tool messages are text-only on the OpenAI chat wire)
- docs: AGENTS.md records the dialect-selection contract