Commit Graph
112 Commits
Author SHA1 Message Date
雷电芽衣 b957e12370 fix(tui): repaint the header context window on a /model switch
After `/model`, the header kept the previous model's window ("15K /
1.0M" on a 262K model) until the next turn or a restart. The pager
caches the window inside its context snapshot (streaming `totalTokens`
updates refresh it from the catalog) and nothing refreshed the cache
when the session's model changed. Comparing cached numbers against
catalog windows cannot tell a catalog-derived total from a shell
effective limit (KIGI_DEBUG_CONTEXT_WINDOW, header upgrades), so the
shell, which installs the effective window, now reports it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 09:31:21 -04:00
ZacharyZhang-NYandClaude Opus 5 9fb82291b2 chore(migration): repoint every published link off GitHub
The canonical repo is git.zacharyzhang.com now, so every URL a user can
actually reach had to move with it.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:13:19 -04:00
ZacharyZhang-NY f656627c10 fix(tui): start the session a sessionless model switch defers into
Launching kigi in a non-project directory (~/Downloads, ~, /tmp — anywhere
`is_project_dir` rejects) leaves the agent view session-less behind the
project-picker question, which only a PLAIN prompt can open. Slash
commands still execute there, so `/model <name> <effort>` stashed its
switch in `deferred_model_switch` — a stash that assumes a create is in
flight — and with none pending it dangled forever with zero feedback:
the picker rendered, the user chose model and effort, and nothing
changed. Not a 0.1.10 regression: 0.1.9 pty-reproduces identically; the
report correlated with the upgrade only because every earlier launch
happened to be from a project directory.

Apply the QueueCommand precedent ("queued slash work bypasses the
picker, so create the deferred session or it never drains") to both
deferral sites — Action::SwitchModel in the router and
set_default_model's no-session branch — via
skip_picker_and_create_session, whose in-flight guard already makes it a
no-op while a create is pending, so the racing-create case is unchanged.
SessionCreated then applies the stash through the existing
apply_deferred_model_switch path; pty-verified end-to-end from
~/Downloads (session created, model changed, effort applied).
2026-07-27 15:55:57 -04:00
ZacharyZhang-NY bac8470c80 fix(hooks): share one SSRF policy instead of a second, stale copy
The hook runner carried its own `is_blocked_ip`, a line-for-line copy of
the pre-hardening web_fetch predicate: loopback allowed unconditionally,
and no TEST-NET, 198.18/15, 240/4, 0.0.0.0/8, multicast, IPv6 site-local
or embedded-v4 wrapper coverage. Hook URLs come from settings, and
project settings from an untrusted repo are loaded today, so the gap is
reachable.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Test strength verified by mutation: with the guard reverted, exactly
`session_credentials_are_withheld_from_a_foreign_endpoint` and
`a_cloned_param_set_cannot_redirect_scoped_credentials` fail.
2026-07-26 23:41:45 -04:00
ZacharyZhang-NY 867b3e110b docs(comments): trim paste-fix commentary to the crucial constraints 2026-07-24 13:09:35 -04:00
ZacharyZhang-NY 7ade135e76 fix(tui): reassemble Windows key-burst pastes so repaste-to-expand works
On Windows, crossterm's console backend never emits Event::Paste: a
terminal paste arrives as a per-character key burst that the event loop
reassembles with timing heuristics. Three of its bounds split real
pastes into fragments:

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

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

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

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

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

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

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00
ZacharyZhang-NY 2524c33a5b fix(chat): relocate tool-result images to a batched user message
C3 (Pi openai-completions relocation): tool messages are text-only on
the OpenAI chat wire — image parts inside role:tool content 400 on
strict validators. Images from a consecutive tool-result run now batch
into ONE synthetic user message appended after the run (a user message
may not interrupt tool responses answering the same assistant's
tool_calls); an image-only result carries a '(see attached image)'
pointer placeholder. Applied at the typed conversion layer, so every
chat dialect gets the universally-valid shape.

Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
2026-07-23 10:38:37 -04:00
ZacharyZhang-NY 301eb61de4 fix(chat): BYOK dialect defaults to Passthrough; dedicated Mistral dialect
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.
2026-07-23 10:31:45 -04:00
ZacharyZhang-NY c950f8087c fix(messages): whitelist image sources and guard empty user content
Cross-provider audit M4/M5/M6 (Anthropic Messages builder):
- Non-base64 data: URIs rode as ImageSource::Url — url sources are
  http(s) only → 400; and the two image paths parsed data URIs
  differently (user path split on first comma, tool-result path on
  ';base64,').
- No media-type whitelist: image/svg+xml and param-carrying headers
  ('image/webp;name=x') reached the wire → 400.
- Empty user content shipped empty arrays/text blocks → 400.

One shared parse_base64_image_data_uri (raster whitelist: jpeg/png/gif/
webp) now serves both paths; rejected images degrade to a SHORT
'[unsupported image]' placeholder (never the multi-megabyte payload);
empty user turns get '[empty message]' (mirrors the assistant guard).

Part 6 of the cross-provider replay audit.

Verified: sampling-types 292 + downstream green, clippy clean.
2026-07-23 01:16:03 -04:00
ZacharyZhang-NY 40c71a8343 fix(responses): provenance-gate foreign turns (Pi transform-messages)
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.
2026-07-23 01:10:51 -04:00
ZacharyZhang-NY a3e3973453 docs(tests): restore doc comments displaced by the summary-test insertion
The new truncation test's doc landed between backend_tool_call_position_
stable's doc and its #[test] attribute (clippy: duplicated attribute).
Each test owns its own doc block again.
2026-07-23 01:02:19 -04:00
ZacharyZhang-NY 9cdc0ccfa3 fix(wire): shared ASCII tool-call id sanitizer, symmetric on both legs
Cross-provider audit R4+M3: the Responses leg passed tool-call ids
verbatim (call_id on both function_call and function_call_output) while
the Messages leg sanitized — and its closure used Unicode
is_alphanumeric, letting CJK ids through to Anthropic's ASCII-only
contract, with no empty-id fallback. Both providers enforce
[A-Za-z0-9_-]+ (the codex 400's own words). One module-scope
sanitize_tool_call_id now serves both builders, ASCII-only, empty → "_",
applied identically on call+result so pairing survives.

Part 4 of the cross-provider replay audit.

Verified: 6081 tests green across the four crates, clippy clean.
2026-07-23 01:00:56 -04:00
ZacharyZhang-NY 6f9f550308 fix(codex): drop bare rs_* reasoning references — stateless backend
Cross-provider audit R3: reasoning captured on stateful api.openai.com
sessions (no include requested) carries a server-issued rs_* id but NO
encrypted_content; replaying that bare reference to the stateless
(store:false) codex backend points at server state chatgpt.com does not
have. adapt_body_for_codex_backend step 3 drops such items (encrypted
ones pass through verbatim). Capture-side include for the API-key path
is deferred: the typed CreateResponse is shared with the xai Responses
leg and changing its bytes needs separate validation.

Part 3 of the cross-provider replay audit.

Verified: sampling-types+sampler+chat-state+shell all green.
2026-07-23 00:55:23 -04:00
ZacharyZhang-NY 2b43f54669 test(conversation): fix code-preview fixture to actually exceed the char cap
The multibyte-truncation test used 80 chars — below the 100-char cap, so
the truncation assertion failed (the previous commit's suite count was
misread; the FIX itself was correct and the panic repro held). 120 chars
now exercises both the boundary safety and the truncation.
2026-07-23 00:51:04 -04:00
ZacharyZhang-NY 6979407f22 fix(conversation): char-boundary-safe code preview in text_summary
The code-interpreter preview truncated at BYTE 100 — a guaranteed panic
on any CJK/emoji boundary in interpreted code. One poisoned history item
then crashed every subsequent request build on every backend (the
summary feeds both the Messages and ChatCompletions builders). Truncate
at 100 chars via char_indices instead. Panic pinned by test.

Part 2 of the cross-provider replay audit.

Verified: sampling-types 287 tests green.
2026-07-23 00:50:29 -04:00
ZacharyZhang-NY 1fa87566d9 fix(responses): drop reasoning items without a native rs_* id
Root cause of 400 Invalid 'input[N].id': '' on chatgpt.com/backend-api/
codex/responses: the Responses input builder replayed every stored
Reasoning item verbatim, and rs::ReasoningItem.id serializes
unconditionally — so foreign items (Messages-captured Anthropic
signatures, chat-completions-synthesized reasoning, stream-delta
fallbacks, legacy upgrades — all id '') reached the wire with an empty
id the API rejects. This also self-poisoned pure codex sessions whose
reasoning arrived only as deltas.

A native item always carries a server-issued rs_* id: empty id = foreign
= unusable by any Responses provider = dropped at the builder — the
exact mirror of the Messages builder's prune_replayed_thinking. The
encrypted-only fixture that pinned the poison shape now uses a native id
(the pass-through case it always meant to cover).

Part 1 of the cross-provider replay audit (Pi transform-messages
policy: builders emit only items valid for their target).

Verified: sampling-types 286 + sampler/chat-state/shell 5791 green.
2026-07-23 00:49:18 -04:00
ZacharyZhang-NY d2358b037c fix(codex): adapt the Responses body to the ChatGPT/Codex backend contract
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.
2026-07-22 23:35:31 -04:00
ZacharyZhang-NY 6ba24db019 fix(messages): replay thinking blocks only for the active tool loop
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.
2026-07-22 23:26:49 -04:00
ZacharyZhang-NY 10149f50dd install: stop persisting KIGI_GRAPH — the binary default is the product default
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.
2026-07-22 21:22:16 -04:00
ZacharyZhang-NY e53a66d113 feat(graph): /graph ships on by default — end the KIGI_GRAPH gray release
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
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.
2026-07-22 21:16:49 -04:00
ZacharyZhang-NY f6eaafa3da build(kigi-shell): retry transient ripgrep download failures with backoff
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.
2026-07-22 20:48:13 -04:00
ZacharyZhang-NY f403c38a94 fix(tui): a default-model persist failure no longer reverts the live model
Persist-failure ≠ switch-failure. The default_model rollback arm re-ran
set_default_model_inner(prev) AND issued a reverse SwitchModel whenever
the config.toml write failed — the only mechanism in the codebase that
deliberately re-shows the ORIGINAL model after a successful pick. On
Windows, where AV/indexer file locks routinely failed that write (until
27d009c), every /model selection appeared to not take.

The session switch succeeds independently and reports its own failures
via handle_switch_model_complete; a disk-persist failure now keeps the
live model, logs, and surfaces only the 'Could not save' toast — the
same policy PersistPreferredModel already ships ('still active for this
session').

Verified: kigi-tui 6874 tests green, clippy clean.
2026-07-22 19:55:02 -04:00
ZacharyZhang-NY 27d009cb6e fix(fs): Windows-safe atomic replace everywhere — model switch now sticks
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.
2026-07-22 19:42:13 -04:00
ZacharyZhang-NY 63ff114f71 feat(model-picker): each row names its connected provider
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.
2026-07-22 17:38:05 -04:00
ZacharyZhang-NY 66d5194ee3 feat(tui): /model + Enter opens the model picker instead of erroring
A required-args slash command submitted bare (/model, or /m) ran straight
into its usage error. The completeness contract already existed —
is_command_complete's documented 'Blocks' row — but had zero non-test
callers; both dispatch paths ran the command unconditionally.

Both the session prompt path and the dashboard dispatch box now consult
is_command_complete before running: an incomplete required-args command
re-opens the input as '/{command} ' with the cursor in the args phase, so
the existing suggestion dropdown lists the choices (for /model: the
catalog — by construction the connected providers' models, after
1bb10ef). Generalizes to every future required-args command; no new UI.

Verified: kigi-tui 6872 tests green, clippy clean.
2026-07-22 17:28:58 -04:00
ZacharyZhang-NY 1bb10ef7f1 fix(models): subscription-OAuth sessions are a fetch source for the catalog
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.
2026-07-22 17:16:38 -04:00
ZacharyZhang-NY fc7c2a1b9b feat(login): /login opens the provider picker with green connected badges
/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.
2026-07-22 16:14:42 -04:00