Skip to content

Project coordination — Phase 1: project_group_id plumb + projects.json store - #1

Closed
SaRedfiche wants to merge 2212 commits into
mainfrom
feature/project-coordination-phase1
Closed

SaRedfiche wants to merge 2212 commits into
mainfrom
feature/project-coordination-phase1

Conversation

@SaRedfiche

Copy link
Copy Markdown
Owner

Why

Phase 1 of project coordination — grouping sibling chat sessions that work the same project, so in-flight work is visible and same-file/same-worktree collisions can be surfaced later. This MR lands the two foundational, independently-verifiable pieces; the tagging API, collision signals, notify, and panel are later steps.

What

  1. project_group_id slot field (85b2ebb26, c255a4a1a) — an opaque per-slot tag plumbed through save/rehydrate (x4 sites), fork inheritance, and workspace-switch clear+rollback. Round-trip tested. (Already gated GO on c255a4a1a.)
  2. projects.json store — ProjectStore (b04ccbad4) — the machine-owned record table ({id, name, repos[]}) a project_group_id points at. Mirrors the crons.json durability pattern (advisory flock, blake2b _sync_for_write read-modify-write, atomic tmp→rename) with none of the scheduler machinery.

Store design: machine-owned, fail-loud

The only writer is ProjectStore (json.dumps + atomic write); we do not accept hand-authored records. So the store defends its own invariants and does not coerce foreign input:

  • create idempotent by id (caller mints the required id); delete always proceeds (a dangling id is a reader concern, not a delete guard); observe_repo appends a deduped, order-preserving repo rollup.
  • Transactional writes — a mutator snapshots, mutates, and rolls the in-memory cache back if atomic_write fails (incl. observe_repo's nested repos), so a failed save never leaves an uncommitted mutation cached.
  • Fail loud, never silently empty — an unreadable file re-raises OSError; an unparseable / wrong-shape file raises ProjectStoreCorrupt; from_dict enforces the exact {id,name,repos} shape (unknown key → corrupt). _last_digest is set only after a fully successful load, so a failed load never poisons change-detection.

The store is intentionally unwired this MR — its validated creation interface (the tagging API) is the next step and is its only intended caller.

Verified

  • Tests: 28 store + 7 field tests green (./.venv/bin/pytest test/test_project_store.py test/test_project_group_id.py), incl. cross-instance TOCTOU dedup, transactional-rollback (create/observe/delete), fail-loud (corrupt/unreadable/malformed/unknown-key), and the digest-poison regression.
  • Multimodel panel (6f5d6a985): GO — GPT / Opus / Design PASS; First-Principles CONCERNS (the acknowledged unwired-store split).
  • Adversarial crew (5 axes): GO — no Blocker/High/Medium; the one convergent finding (from_dict unknown-key vs fail-loud contradiction) fixed.
  • ASH (9211b8df, 10 scanners): 0 findings reference project_store.py; this diff adds 0 actionable findings.
  • Full paired-gate audit trail in docs/scan-log.md.

Upgrade notes

None — additive Python + a new JSON store with zero consumers yet. No IaC, no new dependencies, lock/config files unchanged.

Accepted follow-ups (land with the tagging API)

  • _MAX_REPOS / per-repo-length cap on observe_repo.
  • Workspace-switch rollback uses a value guard vs the _CommitToken identity used for project/workspace — revisit when a concurrent tagging writer exists.

chenmingwei23 and others added 30 commits September 7, 2026 05:06
…irodotdev#9232)

The replay corpus recorded by kirodotdev#9161 predates the wire_title field kirodotdev#9073 added to every tool_call event 3 minutes later, so main's own corpus no longer matches main's own render. Regenerated with scripts/update_acp_frame_snapshots.py; the only delta is the new wire_title key.
…irodotdev#9225)

session/set_mode boots the switched-to agent's MCP servers, the same
(re-)initialization that gives session/new and session/load their 90s
budget, but both set_mode call sites used the generic 30s _REQUEST_TIMEOUT.
A switched-to server pending OAuth holds the response for its full 30s
wait, so the 30s budget races it exactly as it would session start. Pass
the already-resolved session-start budget to both set_mode calls.

Closes kirodotdev#9185
…irodotdev#9237)

The store's _open_lock opened its lock file with open(path, "w"), which truncates on open. On Windows a truncating open of a lock file whose first byte another holder already locked under msvcrt.locking raises a sharing violation instead of waiting, so a second contending acquirer crashes with a bare OSError before it reaches file_lock -- the bind it was serialising is never mutually excluded. POSIX flock tolerates the truncate, so the defect was Windows-only. Fix mirrors dashboard/handlers/mcp.py: touch + open r+ (writable, no truncate).
…le (kirodotdev#9250)

The three PID lock helpers opened their lock file with `open(path, "w")`
and only then acquired the lock. `"w"` truncates AT OPEN, so the
truncation happens before the lock is held.

On Windows the acquire routes to `msvcrt.locking`, and a truncating open
of a lock file whose first byte another holder already locked raises a
sharing violation instead of waiting -- so the contending acquirer
crashes with a bare OSError before it ever reaches `file_lock`, and the
serialisation the lock exists to provide never happens. POSIX `flock`
tolerates the truncate, which is why this was invisible on Linux and
reddened only the Windows shards.

Issue kirodotdev#9248, which named `session_pid.py` as the site most likely to bite
in practice: PID files are read by other processes precisely while they
contend. It named two sites and asked whoever picked it up to re-run the
grep rather than trust the list; doing so found a third,
`_periodic_pid_sweep`, which is the worst of them because it runs on a
timer while `_track_session_pid` contends for the same lock.

All three now `touch(exist_ok=True)` and open `"r+"` -- writable, which
`msvcrt.locking` requires, but not truncating. Same shape as
`dashboard/handlers/mcp.py`'s `_McpFileLock`, which was already written
this way, and as `work_ledger._open_lock`.

Truncation is the direct, platform-independent observable, so the tests
seed the lock file, take and release the lock, and require the bytes to
have survived: they fail on every platform if the truncating open comes
back, rather than only on Windows.

`webhooks.py` carries the same pattern and is deliberately left alone --
issue kirodotdev#9248 asks for one change per subsystem so each gets its own
review.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ls (kirodotdev#9257)

test_two_conductors_binding_one_worker_at_once_yield_exactly_one_binding
fails on Backend Tests (Windows) (4) intermittently and reports only
"assert 2 == 3". Its bind helper caught only WorkLedgerError, so anything
else (a bare OSError from a Windows sharing violation, per kirodotdev#9250's own
docstring) killed the thread silently, appended nothing, and shortened the
count -- discarding the one fact that identifies the cause.

Record one outcome per thread keyed by conductor, catch BaseException and
keep the type + message, and flag a thread still alive after join(timeout).
Put the per-thread outcomes in both assertion messages so the next Windows
failure names its exception instead of a count. The == 1 and == 3
assertions are unchanged: this adds diagnosis, not tolerance.
…rodotdev#9260)

A task card is progress decoration and the 30s elapsed-time refresh
re-sends it for as long as a tool runs, so on a several-minute tool phase
it is the only thing appending to the stream. Both Slack pipelines
rotated the stream when Slack refused that append: the message the reader
was watching was stopped mid-answer and the rest of the reply continued
in a new one, which reads as a reply that failed followed minutes later
by an unexplained second reply.

_append_task now skips the refused card and keeps the stream.
_append_stream still rotates when real text is refused, which is the
moment a rotation is worth its price, and that replacement stream now
opens with a continuation marker so the two messages read as one answer.
…behind a stuck pane (kirodotdev#9141)

A remote crew pane can sit on "loading pane" with nothing in
gateway-launch.log to explain it. The pane journal added in kirodotdev#8274/kirodotdev#8813
covers the viewport's own warm paths, but the one warm-writer it does not
own -- connectInstanceInto, shared by tab clicks and the auto-connect
fan-out -- was silent, and three state changes that put a pane into that
bucket left no line at all.

connectInstanceInto now journals `warm` / `warm-declined` / `warm-failed`
tagged `via=select|auto-connect`, in the same shape the viewport uses. A
connect that answers `connected` with no port or no token leaves the OLD
warm entry standing (a dead port), so the tab still renders an iframe and
the user sees only "loading"; that is now the one `warm-declined` line it
was missing.

InstancesViewport journals three more moments:
- `evict`: the K-cap LRU eviction, the only teardown the user never asked
  for. The next click re-warms the pane as a brand-new load, so an evicted
  pane that then fails reads, without this line, as a pane that was fine
  until it suddenly was not.
- `watchdog-armed`: the 15s load watchdog starting. A pane that spins
  forever WITHOUT ever producing `load-timeout` is a pane whose watchdog
  never armed, and only the absence of this line says so.
- `ready-unattributed`: an `mc-embedded-ready` from a loopback origin the
  parent cannot map to a warm pane -- the handshake dropped because the
  origin map has not caught up with a port change or an eviction. Journaled
  only for this message type and only for a loopback origin; the child
  sends it at most six times per load, so it cannot flood.

No token material reaches the log: `paneLog` redacts secret-named fields
and the new test pins that the token string never appears.

Co-authored-by: Joe Guo <zejiangg@amazon.com>
This reverts commit ecf50ca (kirodotdev#9158).

Fable 5.1 is listed ACTIVE on Bedrock in this account and its us.
inference profile exists, but it is not actually invokable from these
lanes: every first call is rejected at zero tokens. The three advisory
lanes hid that behind --fallback-model, so they reported success while
modelUsage named us.anthropic.claude-opus-4-8, and the GPT lanes'
adjudication pass carries no fallback, so it failed outright and turned
the required GPT 5.6 Review check red on four pull requests inside
sixteen minutes.

Listing a model as available was not evidence that the role may invoke
it. Re-landing the bump needs a real InvokeModel check first, and the
advisory lanes need it too: their green was the fallback, not Fable 5.1.

The two corrected labels for the Opus-4.8-running claude-review.yml lane
go back to saying Fable 5 with this revert. That is pre-existing wording,
wrong but harmless, and it returns with the retry rather than holding up
an urgent revert.
This reverts commit 259db39.

Restore the Windows Kiro delegation contract: the macOS settings probe
must not prevent an explicitly classified official Kiro backend from
starting on a fresh Windows install. Kiro CLI remains a trusted runtime
dependency; its internal sandbox is outside this Crew-side validation.

Revert the whole PR for immediate recovery, including its UI-stream
deadline and debug-only precise-memory-info switch. Remove the obsolete
stalled-stream test together with its subsequent determinism changes.

Validation on this branch: 140 backend tests passed, 5 platform skips;
23 Electron native-logging tests passed. Changed Python files pass
isort and flake8; the staged diff passes the whitespace check.
…ile (kirodotdev#9279)

The two writers of hooks.json — webhooks.locked() and the register_hook
MCP tool (mcp_tools/control.py) — both opened their shared lock sidecar
with open(lock_path, "w") and only then acquired the lock. "w"
truncates AT OPEN, so the mutation happens before the lock that is
supposed to guard it is held.

On Windows the acquire routes to msvcrt.locking, and a truncating open
of a lock file whose first byte another holder already locked raises a
sharing violation instead of waiting — the contending acquirer fails
before it ever reaches the lock, and the mutual exclusion the lock
exists to provide silently does not happen. POSIX flock tolerates the
truncate, which is why the defect is invisible on Linux.

These two sites are ONE unit: both resolve to the SAME
<data-home>/hooks.json.lock from two different modules, which is exactly
the cross-process contention the defect needs — webhooks.locked() is
documented public precisely because register_hook must share its lock
discipline. Fixing one without the other would leave the pair
half-locked and the Windows failure fully live.

Both sites now touch(exist_ok=True) and open "r+" — writable, which
msvcrt.locking requires, but not truncating. Same shape as
work_ledger._open_lock (PR kirodotdev#9237), session_pid.py (PR kirodotdev#9250), and
dashboard/handlers/mcp.py's _McpFileLock, which was written this way
from the start.

Truncation is the direct, platform-independent observable, so the tests
seed the lock file, take and release the lock, and require the bytes to
have survived: they fail on every platform if a truncating open comes
back.

Part of the kirodotdev#9248 sweep (one subsystem per PR, per the issue); the
remaining subsystems get their own follow-up issues.

Co-authored-by: dwu96 <dwu96@amazon.com>
Co-authored-by: Kiro Crew <noreply@kiro.dev>
…irodotdev#9262)

A runner that submits to a pool and returns the unwaited
concurrent.futures.Future hands the work back, so the SDK holds the only
reference to it for one instant. _close_quietly already asked that
reference to cancel -- as hygiene -- and discarded the answer, while
_undriven_result had already worded the record as work that 'may still be
running ... and cannot stop'. So a run whose work provably never started
was recorded as work a retry could overlap.

_stop_returned_work now makes that stop deliberate, takes it before the
verdict is worded, and answers three ways: True only for a
concurrent.futures.Future whose cancel() returned True, which that class
documents as the call never running; False when the work is already
executing; None when no guarantee is available. An asyncio.Task can
answer cancel() with True and finish anyway, so a truthy cancel is never
read as a stop.

Refs kirodotdev#7814

Co-authored-by: Kiro Crew <noreply@kiro.dev>
kirodotdev#9275)

The 18 lock context managers in issue_radar's store.py and crew_store.py
opened the lock sidecar with open(lock_path, "w") and only then handed the
descriptor to platform_compat.file_lock(..., exclusive=True). "w" truncates
at open, before the lock is held. On Windows the acquire routes to
msvcrt.locking, and a truncating open of a lock file whose first byte another
holder already locked raises a sharing violation instead of waiting, so the
contending acquirer fails before it reaches the lock and the mutual exclusion
silently does not happen. POSIX flock tolerates it, which is why it is
invisible on Linux.

Each site now ensures the parent dir, lock_path.touch(exist_ok=True), then
open(lock_path, "r+") -- writable (msvcrt.locking needs it) but not
truncating -- acquiring on that handle exactly as before. Same shape and
rationale as work_ledger._open_lock (kirodotdev#9237) and session_pid.py (kirodotdev#9250).

Refs kirodotdev#9248
POST …/{id}/import takes a host path to an audio file, transcribes it with
the gateway's own batch speech-to-text, and dispatches each line through the
same admission transaction live speech uses, so an imported recording is
persisted to the transcript and reaches the agents exactly as if spoken.

Every dispatched line requires the SESSION OBJECT admitted at import start —
a meeting id is a name, not an identity — so a meeting stopped, deleted, and
recreated under the same id mid-import gets a 410 (meeting_session_replaced)
instead of the old recording's lines.

A recording that would split into more than MAX_IMPORT_LINES lines is
refused whole with 413 (recording_too_long) rather than silently truncated:
a capped import that returns success while the recording's tail is missing
is data loss the user cannot see.

Original feature authored by Kai Mitsuzawa (kaizawa97). Rebase onto main,
locale conflict resolution, the session-identity fix, and the overflow
rejection by Kiro Crew.

Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
…irodotdev#7096)

Apply reasoning effort overrides post-claim on warm-pool processes via provider.change_effort, keeping the warm-pool hit path active for slots carrying reasoning effort overrides.

Fixes kirodotdev#6248
…on (kirodotdev#7249)

cron_add/cron_update (MCP) accept a 'folder' argument (name or id; a
missing name is created through POST /api/cron-folders, the dashboard's
own endpoint, so the create shares the Schedule page's lock and
in-memory list). 'kirocrew cron add' gains --folder (existing folders
only). App manifests can set 'folder' on a cron entry (a NAME, resolved
at registration; unresolved degrades to ungrouped with a warning and is
re-applied on the next enable, so the assignment survives the
disable/enable delete-and-recreate cycle).

Shared read-only resolver in cron.py (load_cron_folders /
lookup_cron_folder_id): cron_folders.json stays dashboard-owned; no
non-dashboard surface writes it directly.

Co-authored-by: Aurélien Andrey <aurand@amazon.fr>
…tant (kirodotdev#7265)

`generate_token` mints `exp = now + min(LINK_WINDOW_SECS, session_ttl)` so a
link can never authenticate past the session it grants. Both Slack surfaces
that report that window still printed the unclamped constant, so
`/kirocrew dashboard 1m` promised "Click within 5m" about a link that dies in
one — a 5x overstatement of a window the user has to act inside.

Report the clamped value at both reporters: the DM built in
`allowlist.send_dashboard_link` and the ephemeral block built in
`events._handle_dashboard`. For a full-length caller both strings are
byte-identical to before, because the clamp only ever tightens.

Original change authored by Leon (@leonlaiyc).

Co-authored-by: Leon <276034861+leonlaiyc@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Add a default-off Lexical composer path with atomic inline paste nodes, editor-neutral selection and focus controls, shared clipboard policy, lazy-load rollback, and focused regression coverage while preserving the textarea fallback.
…#8528)

A pod isolates KIROCREW_HOME and KIRO_HOME but deliberately keeps the real
HOME, and kiro-cli derives its MCP OAuth artifact directory from the spawned
process's own $HOME. So a pod REUSED the operator's machine-level grants (a
provider card read Connected from a grant minted on the real machine) and a
grant minted INSIDE a pod outlived pod down as a real, durable credential.
Both break test fidelity and the destroy-grants-after-each-smoke-test
requirement.

Two halves, one shared resolver:

- mcp_grant.kiro_oauth_cache_dir() -- the single default all four callers
  (mint, status, disconnect, mcp_discovery's remote probe) reach with no
  explicit cache_dir -- now resolves through the new
  config.paths.kiro_oauth_cache_home(), which honours KIROCREW_OS_HOME and
  rejects the same unsafe targets as kiro_home()'s KIRO_HOME. This moves the
  pod gateway's own grant reads onto the pod's tree.
- acp.client._apply_pod_home_remap(), applied identically at both spawn
  transports, remaps a pod-spawned kiro-cli child's HOME/USERPROFILE to that
  same directory. kiro-cli offers no env override for just its OAuth cache,
  so remapping the child's HOME is the only way to move its own WRITES.
  Gated on KIROCREW_POD=1 plus positive membership in
  ACP_BACKENDS_POD_HOME_REMAP -- its own set, not a reuse of the
  internal-sandbox one, because "carries its own OS sandbox" and "relocating
  HOME moves its credential store" are different questions (harness-parity H6).

Sign-in still works: pod.runtime._seed_pod_os_home create-only stages the
AGENT RUNTIME's own identity store -- the per-platform paths derived from
identity_stores.store_mappings(), which is where the harness actually resolves
its access token -- into the pod tree at boot. No host .aws/sso/cache contents
are copied: an earlier revision staged those and it was deleted, so a pod's
.aws/sso/cache starts EMPTY and holds only grants that pod itself mints. A pod
therefore cannot be pre-authorized to a provider nobody consented to inside
it, by construction rather than by a filename glob.

A boot viability probe (agent_sdk.pod_child_probe) is the last gate before
serving: it spawns the child through the same confinement production uses
(env scrub, sandbox wrap, cgroup scope, rlimit preexec) and routes an
unbootable child through _refuse, so a signed-out or dead child makes pod up
fail loudly with a terminal exit code instead of serving a broken pod.

AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE are deliberately NOT exported
into the pod child. An earlier revision pinned both at the real home so a pod
turn could still reach the operator's profiles; that made each name an alias
for a path the sensitive-path keystone fences, and three review rounds each
closed one spelling of the retrieval. The alias was deleted at its source
instead -- security.py records the full reasoning, including why a name-based
deny rule is defensible for the variables that hold a SECRET and not for the
ones that hold a PATH.

The real passwd home stays fenced: security.py's matchers run in the gateway
process against its own Path.home(), and the remap only ever mutates the dict
handed to the child spawn, never os.environ.

The os-home tree nests under the pod home, so cleanup_home's existing pod
down sweep reclaims a pod's grants with everything else.
…irodotdev#8546)

The Crew Members page landed on an empty column every visit and forgot
which member was open when the user navigated away and back. Now:

- A visit that names no member opens the remembered one, else the first
  row in display order — never the empty column.
- The last member opened is remembered per browser (mc-members-last-member,
  via safeStorage), so leaving for Sessions/Settings and returning, or a
  reload, lands on the same conversation. A remembered member that was
  deleted or renamed falls back to the first row without an error.
- The open member rides the URL (?member=<name>): a reload keeps it and a
  link lands on one. Switching members REPLACES the entry — the page holds
  one history entry however many members are visited, so Back leaves it in
  one press, the Sessions sidebar's rule; only the below-md roster->thread
  step pushes. The value is the exact crew name, not the slug — the slug
  is lossy and would misroute Oncall to oncall. A link naming a member
  that is gone takes the same fallback and says so in a warn-toned status
  above the thread, leading with the swap ("Showing X — “Y” is no longer
  on the roster."; 13 catalogs + en-XA): the user asked for someone
  specific, and a silently mounted other thread is the misroute this page
  exists to prevent. The stand-in opened by that fallback is the page's
  choice, not the user's, so it does NOT become the memory — one dead
  link cannot overwrite the member the user had chosen.
- Below md the page stays a two-level list->detail navigation: no
  ?member= IS the roster, so no auto-open there (same rule as
  SidePanelLayout's remembered tab). A stale ?member= returns to the
  roster with the same notice above the list. The header back pops the
  entry the roster pushed (no duplicate roster entry in history) and
  drops the param in place for a deep link, which has no roster behind it.

Tests cover default, restore, stale fallback (memory: silent; URL: with
notice on both sides of md, retired on the next open, memory preserved),
URL-over-memory, click writes URL+memory, a driven-history walk over two
members that leaves the page in one Back, and the below-md back semantics
(the useNavigate mock records AND performs against the MemoryRouter).
…dotdev#8611)

The crew member DM session's kirocrew-dashboard MCP entry carried only the
session key (and, since kirodotdev#8837, the bound port). The server resolves *which
gateway* to call from its data home, so on any install where KIROCREW_HOME is
set -- a pod, a second profile -- it presented the member's identity to the
default home's gateway, which has no such member slot and refused every verb
as caller_unidentified while tools/list looked healthy.

Carry the same home override every managed Crew server already carries
(_managed_mcp_env), through the same helper so the two cannot drift. On a
default install the helper returns {} and the emitted entry is unchanged.

The per-member conversation index (kirodotdev#8612) no longer rides in this PR; it
travels with its consumer, kirodotdev#8613, which is stacked on this branch.
…and offload async callers (kirodotdev#4767) (kirodotdev#8906)

KiroCrewConfig.save() was an unlocked whole-document replace: its rename
could land inside an update_config_locked holder's read-modify-write (CLI
writer, boot refresh, second gateway), silently discarding the other
writer's change -- every field in the document exposed.

Two halves, landing together per the recorded failure modes of the first
attempt (kirodotdev#4371):

HALF 1 -- lock the writer. The sidecar acquire in update_config_locked is
extracted into _config_write_lock() and save() now writes under it: the
SAME <config>.lock sidecar, beside the resolved symlink target, so both
writer families contend on one lock and the lifecycle stays one shared
file (no new residue -- the Windows orphan concern from kirodotdev#4371). The write
itself stays atomic + mode-preserving via write_config_atomically, and
the overlay-subtraction content is untouched. save()'s docstring now
states the staleness contract plainly: it publishes an in-memory
snapshot, so it is ONLY for single-flow callers with no suspension
between load and save (CLI one-shots, the boot default-config write);
read-modify-write flows belong on update_config_locked.

HALF 2 -- dashboard writers become DELTA read-modify-writes, off-loop.
Every .save() call site was enumerated and classified. Coroutine callers
no longer call save() at all: each persists the keys it owns as a delta
mutate through update_config_locked -- read, re-check, and write inside
ONE flock hold -- dispatched off the loop via run_config_write (the
transaction shape its docstring prescribes) or, where the asyncio config
lock is already held, via the module's cancellation-draining
_drained_to_thread:

- dashboard/handlers/updates.py api_log_level: delta on
  agent.log_level
- dashboard/handlers/files.py workspace create/update/delete: delta on
  the one workspaces entry, with the state-dependent preconditions
  (name/dir collision, default-workspace and agent references) re-run
  against the document as read inside the lock and surfaced as 4xx
  conflicts. The copy_from tree is staged only after ALL path validation
  passes, installed inside the locked mutate (atomic os.replace, or a
  merge copy when the destination pre-exists), and every cleanup of a
  staged tree runs in a worker, never inline on the loop
- dashboard/handlers/agents.py agents-sync + agent-create: delta on the
  agents entries (adds re-checked, package/aim prunes re-derived, name
  conflicts re-checked against the in-lock document), via
  _drained_to_thread under the held asyncio lock
- dashboard/handlers/onboarding_import.py: both former manual-lock sites
  route through run_config_write; the state PUT is a delta on
  dashboard.import_onboarded; the unused local _get_config_lock wrapper
  removed
- cli_server.py gateway-boot default-config write: asyncio.to_thread
  (file absent, nothing to lose, no loop-side writers yet)
Sync CLI/wizard callers (cli_commands, cli_config, cli_cloud,
cloud/wizard) stay on save(): single-flow, no suspension between load
and save. crew_chat.py st.save()/self.save() are CrewStore, and mochi
stats_service self.save() is its own persistence -- not KiroCrewConfig.

Tests: red-before-green concurrency pair (save waits on a held sidecar;
a locked writer landing inside save's critical window survives), sidecar
lifecycle (only the one shared .lock beside config.json; symlinked
config locks beside the target), in-lock precondition re-check suite for
the workspace handlers (stale snapshot passes, fresh on-disk document
refuses, unrelated entries survive the delta), staging-lifecycle tests
(no destination mutation or staged residue on conflict or failed
validation), a runtime off-loop probe on the log-level handler, and a
self-tested AST ratchet forbidding any coroutine from calling
KiroCrewConfig.save() inline.

Closes kirodotdev#4767

Co-authored-by: Nick Bowers <nrb@amazon.com>
…rodotdev#8957)

Text-to-speech needed an install before it could say anything: Piper wants a
binary and a voice model on disk, Polly wants AWS credentials, so turning on
auto-speak on a fresh machine produced silence and a settings panel with two
paths that both start with homework.

Add a third provider, `system`, and make it the default. It drives the speech
engine the operating system already ships -- `say` on macOS, System.Speech
through Windows PowerShell 5.1, `espeak-ng` on Linux when present -- so
auto-speak works with nothing downloaded and nothing configured. Linux is the
one platform where the engine can be absent; that is reported as unavailable
with the package to install rather than failing silently at synthesis time.

The engine is resolved through `trusted_system_bin`, not PATH, so a shim in an
agent-writable directory cannot be handed model output, and the spoken text
never reaches argv: `say` and `espeak-ng` read it on stdin, and the Windows path
writes it to a temp file that a constant base64 -EncodedCommand script reads,
so no quoting decision is ever made about model output.

This provider deliberately does NOT route through `wrap_argv`. Windows has no
sandbox backend, so a wrap fails closed on the one platform whose built-in
engine users depend on, and there is nothing to confine in an OS component
resolved from a directory the user cannot write. Piper keeps its wrap: it runs a
downloaded model through a third-party binary. A test pins the distinction.

Also fixes two things the new provider surfaced: `_resolve_piper_binary` looked
for `~/piper-venv/bin/piper` on every platform, so a correct Windows install was
never found (`Scripts\piper.exe`), and the module documented Piper as having no
Windows build, which the `piper-tts` wheel has published since 1.3.0.
…start (kirodotdev#4093) (kirodotdev#8982)

POST /api/chat/slots/{slot}/note replies 200 with visibleDeferred: true for
a note held during a running turn, but both halves of the hold lived only
in _ChatSlot._deferred_notes — a gateway restart between the 200 and the
next turn silently voided the delivery promise.

The hold now persists through the slot's own metadata line under one
invariant: retirement is ROW-DERIVED. Every note carries an id; the flush
stamps each delivered inject row with it (meta.noteId) and records
rebind-dropped ids on the slot; the full save — reading the on-disk and
live holds UNDER the history lock — retires exactly the entries whose
rows are in the window it writes (or whose ids were dropped) and keeps
everything else. Row and retirement land in one atomic file replace; the
drop records are consumed only AFTER that write commits, since a dropped
note's row never exists and the record is its only retirement path.

- enqueue (durable-before-200): asyncio.to_thread + update_metadata_if,
  everything read inside the lock-time guard; the write MERGES by note id
  (union_deferred_notes), pins the posted note (ensure=) so a racing
  turn-end flush cannot yield a 200 with no durable copy, and pins the
  TARGET to the history key authorized at enqueue, re-verified under the
  store lock — a cron/workflow rebind in the persist window is refused
  (uniform not-found shape) instead of writing app content into a foreign
  transcript's metadata. The union never evicts: at the 2x ceiling the
  NEW note is refused (429). On EVERY failure branch, a rollback that
  finds the note already drained means a flush DELIVERED it — the 200
  stands, because any error would make the caller re-post a line the
  user already saw. Other failures roll back BY IDENTITY and answer a
  retryable 503 (including an UNREADABLE record)
- deferred notes are bounded at the ENQUEUE boundary (413 over 4000
  chars); the durable copy is persisted VERBATIM, never truncated
- both restore paths replay the hold up to the durable CEILING (2x the
  live cap — every durable entry is a 200-acknowledged note); restored
  notes are sanitized fail-closed (no session stamp -> dropped;
  over-bound content -> dropped; malformed context half dropped alone)
- the flush never writes metadata; a crash between flush and save
  re-delivers on restore (at-least-once)
- docs updated in the same commit (App Kit api-reference, session.md,
  endpoint docstring): do not re-post after a restart; 503 = retry,
  413/429 = boundary refusals, 404 = ownership/rebind refusal

_pending_context (the /context queue) stays memory-only: only the context
halves embedded in held notes ride the same metadata shape naturally.

Closes kirodotdev#4093

Co-authored-by: Nick Bowers <nrb@amazon.com>
…dev#9032)

The knowledge store already models a namespace label on items (with an index), and the dashboard already lists, assigns and browse-filters by it. Search was the one verb the label never reached: HybridRetriever and local_knowledge_search scoped only by source_id.

Add an optional namespace filter to the search seed legs (keyword + vector), mirroring the existing source_id scoping. namespace is a plain items.namespace column match -- a relevance/organisation filter, not a security boundary -- and composes with source_id. The graph leg stays unfiltered, as it is for source_id.

Refs kirodotdev#8266

Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
…irodotdev#9070)

* fix(portability): keep the export archive inside the crew directory

`create_export_zip` walks `workspace`, `plan_memory` and `skills` with
`rglob("*")` and skips an entry that `is_symlink()`, so that an export never
carries bytes from outside the crew directory. That skip is blind to the escape
that costs the most: `rglob` DESCENDS a directory link, and the file on the far
side is an ordinary one — `is_symlink()` false — so it is written straight into
the archive. On Windows the link is typically a junction, which `is_symlink`
does not report at all, and a junction needs none of the privilege a directory
symlink needs there.

The two filters below the skip do not catch it either. `_is_excluded` and
`is_sensitive_path` both read the LEXICAL path, which runs through the link's
own name and therefore looks like ordinary crew content — so a sensitive file
behind a link passes the sensitivity check that exists to stop exactly this.

Measured on unpatched main with a real junction: `workspace/memory/linked/
not-ours.md` appeared in the export's namelist, sourced from outside the crew
directory. The consequence class is content the user then hands to someone else,
since an export archive is made to be moved off the host.

Fixed by requiring the RESOLVED path to stay under the resolved crew directory —
the repo's existing idiom (`apps/backend.py:651`). Resolving is what covers both
shapes at once: it follows every reparse point on the way down and answers where
the bytes actually live. `mc` is resolved once so a legitimately linked
`$KIROCREW_HOME` does not reject the whole export. The `is_symlink()` skip is
kept as-is; this adds a layer rather than replacing one.

The import side of this module is untouched.

`test_export_skips_symlinks` cannot cover this — it plants a link that IS the
entry, and it `pytest.skip`s where symlinks cannot be created, i.e. on the
platform where the junction spelling lives. The new tests use
`conftest.make_dir_link` and need no privilege.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portability): archive the descriptor that was validated

`create_export_zip` walked `workspace/`, `plan_memory/` and `skills/` with
`rglob("*")`, which DESCENDS a directory link while the file on the far side
is an ordinary one, so the walk's own `is_symlink()` skip never fired for it.
Neither by-name filter below that skip is a containment test either:
`_is_excluded` is a rule about the archive name, and `is_sensitive_path` asks
whether a path is a PROTECTED location -- it does resolve links, so a linked
`~/.aws` was caught -- never whether it is inside the crew directory. An
ordinary file of the user's, reached through a link dropped in the workspace,
was sensitive to none of them and went into an archive the user hands on.

A resolve-and-compare would answer that for an instant only: `ZipFile.write`
takes a NAME and opens it again, so the file that was checked and the file
that is read are two separate lookups, and a running gateway gives agent
tools write access to that workspace while an export can be triggered.

`_open_verified` inverts the order -- open first, then ask the kernel where
the open thing is via `pinned_fs.fd_real_path` -- and `_add_from_fd` streams
the entry from that descriptor, so check and use address one object. A
hardlink alias, which no path-based guard can see, is refused on the
descriptor's link count; `force_zip64=True` keeps a source over `ZIP64_LIMIT`
archiving as `ZipFile.write` used to.

That settles what may be ARCHIVED. It does not settle what may be TOUCHED,
and on Windows those are different questions: resolving a path whose
component is a link to a UNC share IS an outbound SMB authentication, so a
refusal computed afterwards has already paid the cost it exists to prevent.
No pathname check fixes it -- every by-name check is a check-to-open window
and an adversary that can plant the link chooses when. The names have to be
held rather than inspected.

The enumeration was the last place that still inspected them. Pinning the
candidate's ancestors closed the swap race, but `rglob` had already descended
a PRE-PLANTED junction, and `is_file()` and `is_sensitive_path()` had already
resolved what it yielded, before any of it ran. Measured on this module,
exporting a workspace holding one planted junction: 14 resolving calls went
out through it -- `os.path.realpath` and `ntpath._getfinalpathname` -- while
the export correctly archived nothing from behind it. Nothing being archived
was never the question.

So the walk descends and verifies in one motion instead. `_walk_pinned` pins
a directory with `platform_compat.pin_directory` before listing it -- the
handle omits `FILE_SHARE_DELETE`, so a held directory can be neither renamed
nor deleted, nor can anything above it, and the open refuses to follow a
reparse point, so a junction at the name fails there instead of being
traversed -- then classifies each child from the listing Windows already
returned, where the attributes and reparse tag arrive inline and cost no
lookup THROUGH the child. A child directory is descended only by pinning it
in turn, so the path the kernel walks to reach any component runs entirely
through components already opened and verified, starting at the crew root
itself. This is the pattern `aws_control/backend/storage.py` already uses to
hold the path a sandboxed CLI writes through. The leaf goes through the new
`platform_compat.open_file_no_reparse`, which opens a reparse point AS ITSELF
rather than following it, so the refusal and the open are one operation;
POSIX keeps the `O_NOFOLLOW` open it already had, named.

`_pin_ancestors` and `_open_inside` are gone rather than kept alongside this:
the walk holds the same chain, for the whole subtree instead of re-walking it
per file, so keeping them would be two mechanisms doing one job.

The walk starts at the RESOLVED root, since `$KIROCREW_HOME` may itself be a
link and `pin_directory` refuses a reparse point at a name -- pinning the
configured spelling would return an empty export on such a host.

The exported set is unchanged: on a tree covering nesting, `EXCLUDE_DIRS`,
`EXPORT_EXCLUDE`, `.pid` and `skills/auto`, the archive is byte-identical to
the one the previous walk produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ty (kirodotdev#9071)

An Issue Radar crew driven by an auto-nudge cycle reaches its ledger
tools through the same _run_chat entry a human turn uses, which resolves
identity as effective_session_key(slot) and hands it to the one shared
publish_turn_identity writer. So a nudge turn and a direct turn on the
same slot resolve the SAME session identity, and the strict gate lets
the crew through -- the property kirodotdev#5905 assumed was broken.

This is the positive twin of the existing subagent refusal test: a
subagent resolves to its PARENT slot and is refused; an auto-nudge crew
is the same principal re-entered on a timer and is admitted. Nothing
pinned that the nudge fire path keeps routing through the shared writer,
so a future refactor of _fire_dashboard_nudge could silently reintroduce
the confusing refusal. These regression tests assert the property (same
identity, one shared writer), not the call ordering.

Also prunes the file from the black shrinking-baseline now that it is
black-clean.
…ev#9149)

The Connections gallery was merged on main but held behind an opt-in
`connections_ui: true`, so a shipped build reached it only after a
hand-edit of the instance's config.json. Flip that default: the gallery
is now on for every install that never touched the flag, while
`connections_ui: false` remains the escape hatch that empties the
Services panel and puts chat back as the only authorize prompt.

The key discovery is that holding GitHub back cost NO new mechanism.
A per-provider launch gate already exists on both sides of the seam --
`launch_gate_passed` in src/kiro_crew/connections/registry.json, filtered
by `get_visible_providers()` in registry.py and by `CONNECTION_PROVIDERS`
in website/src/pages/connections/registry.ts -- and GitHub is already
`launch_gate_passed: false` there because its OAuth-app registration
under the kirodotdev org is outstanding. So the launch set is the six
providers those filters already produce (Notion, Linear, Atlassian,
Stripe, Vercel, GitLab), and the flip adds zero production lines for
per-provider gating. `test_only_gated_launch_services_are_visible`
already pins GitHub's exclusion on the backend; this change adds the
matching assertion on the frontend's exported list.

The predicate keeps the old "never guess from a sloppy value" rule and
points it at the safe direction, which is now off. An absent key is ON,
an exact `true` is ON, and everything else present -- including a
hand-edited `"false"` -- is OFF, so an opt-out cannot silently fail to
take. A config that has not been read yet (undefined, and a failed
fetch) stays OFF: the opt-out lives in that config, so opening before
reading it would flash a gallery the user disabled and fire its status
queries on their behalf. That preserves today's loading behavior exactly
rather than introducing a new transient.

KNOWN DEPENDENCY, not fixed here: on a live gateway the flag never
reaches the browser at all, because GET /api/config/kirocrew strips
unknown top-level keys (`_masked_config_dict` pops `_extra_sections`).
The default-on half of this change works regardless -- it is the
absent-key path. The `connections_ui: false` escape hatch only becomes
functional once fix/connections-ui-flag-roundtrip makes the key
schema-known. Landing this before that fix therefore ships a gallery
with no working opt-out, which is why the flip is merge-gated on a human
re-test.

Prose that asserted the feature was unreleased is updated in the same
pass so the code no longer contradicts itself: the hook docstring,
CapabilitiesPage's flag docstring, ConnectionsPage's `servicesEnabled`
docstring and its empty-list comment, and McpTab's mint-engine comment.
ConnectionsPage's parameter default stays `false` so a caller that omits
the prop cannot open a gallery by omission.

Tests: ConnectionsUiGate.test.ts is rewritten for the new default
(absent key opens, explicit false closes, unread config closes, sloppy
values close) and gains a launch-set block asserting GitHub is absent
and every offered provider passed its gate. McpTab.test.tsx re-points
its baseline mock at the shipped default, converts the chat-prose
fallback case to an explicit `connections_ui: false`, and adds a case
proving the in-place sign-in reaches a managed row with no flag key at
all. No user-facing strings were added, so no i18n catalog changes.
…irodotdev#7820) (kirodotdev#9189)

A GitHub issue-prefill link is redacted in chat as
`[REDACTED: suspicious URL to github.com]`, which kirodotdev#7820 reports as a false
positive. Two earlier rounds of this change waived the aggregate query-LENGTH
signal for that shape -- first on the validated shape alone, then additionally
pinned to this project's own tracker. Both are withdrawn. Neither was safe, and
the difference between them was only who reads the payload.

What reaches `redact_exfiltration_urls` is MODEL-AUTHORED text:

  injected content steers the model into emitting a prefill URL whose `body`
  carries percent-encoded private context; the waiver skips the length check;
  the link renders as the familiar "file an issue" affordance; the user submits
  it; and the issue is PUBLIC, so the attacker reads it.

Pinning the repository does not close that -- this project's tracker is
world-readable by design. A URL's shape says nothing about who authored it, and
a marker placed IN the text travels in the channel the injection controls. So no
validation performed on a model-emitted URL can establish provenance, and that
is a property of the class rather than of any one spelling of the check.

- Remove the frontend waiver: `isPrefilledIssueUrl` and the `EXFIL_ISSUE_*`
  constants in `website/src/utils/sanitize.ts` (they landed for kirodotdev#7824). The
  aggregate-length signal there is unconditional again. This is the production
  change in this commit.
- The BACKEND needed no deletion after rebasing onto kirodotdev#9183, which split
  `security.py` into a package: `security/exfil.py` was re-derived from a base
  that never carried the waiver, so its length gate is already unconditional.
  What lands there is the reason it must stay that way, recorded at the gate so
  the next person with a long legitimate URL narrows the heuristic for every
  host instead of carving out a shape.
- Deliver kirodotdev#7820's feature through the TRUSTED STRUCTURED CHANNEL the product
  already ships, rather than through the redactor. `diagnostics._issue_url`
  assembles the prefill query from structured fields (version, channel,
  what-happened, context, install) with `urlencode`; it travels as
  `BundleResult.github_issue_url` on a JSON response no redactor scans; and
  `ReportProblemModal` / `ReportProblemCard` render their own
  `<a href={result.github_issue_url}>`. Nothing on that path is text a model
  wrote, so nothing on it needs a waiver. `diagnostics.py` already documented
  this split, with `terminal_issue_url` as the bounded variant for paths that DO
  get relayed through prose.
- Repoint the parity guard from pinning a carve-out to pinning its ABSENCE on
  both halves, reading each enforcing source at run time. The backend sweep runs
  over the whole `security/` package rather than one module, because a waiver
  reintroduced in any of the nine modules or in the facade would be just as live
  -- the single-file path this replaces was left pointing at a deleted file by
  kirodotdev#9183, which is exactly the failure mode. The length comparison is asserted at
  its own site on each side, so a waiver spelled with fresh identifiers still
  fails, and the structured seam is asserted present because deleting the waiver
  is only free while that seam exists.

Red-first against the pre-fix head: a prefill link to this project's own tracker
with an encoded payload rendered as a live link.

The withdrawn allow-list held GitHub's classic prefill keys, while the trusted
builder uses issue-FORM keys, which that list never contained -- so the waiver
only ever served links the model typed, the exfiltration surface and nothing
else.

Refs, not Closes: kirodotdev#7820 asks for a narrower heuristic and also reports
`monitorportal.amazon.com`. Both are still redacted in chat, and a test pins
them as getting the same verdict. Narrowing aggregate query length is a per-host
security-ceiling decision on its own merits, and a per-shape escape hatch is not
a substitute for it.

Refs kirodotdev#7820
)

The image viewer opens with a SET -- dispatchLightbox() collects every
img[data-lightbox-image] in the nearest [data-image-scope] -- but the only way
through it was ArrowLeft/ArrowRight. On a phone there is no keyboard, so every
image after the first was unreachable and nothing on screen said the set existed.

A horizontal drag now pages through the set. The one-finger overlay gesture locks
an AXIS once it crosses the slop, then either pulls down to dismiss (unchanged) or
sideways to page, so a diagonal drag can no longer do both. Four product decisions,
each matching an existing decision in the same component rather than inventing one:

* Commit on DISTANCE only, like the dismiss path, and at a shorter threshold (64px
  vs 96px): paging is reversible, a dismiss destroys the viewing context, so paging
  can commit on less travel.
* Rubber-band at index 0 and at the last image, reusing the divisor the upward
  dismiss drag already used -- a silent no-op at the ends reads as a broken gesture.
* Show position in the set ("2 of 4") on an aria-live pill. Without an affordance
  the gesture is invisible, and this is also the first thing to announce the image
  change to a screen reader.
* Own the gesture only at fit zoom, exactly as dismiss does; above fit a horizontal
  drag is already pan, handled on the <img>.

Owning the horizontal axis here is safe because the app-wide nav drawer yields to
any element whose computed touch-action is none, which the overlay already set to
take page zoom. The keyboard path, the pinch, the double-tap and the desktop
click-to-close are untouched.

Closes kirodotdev#5799
bolichen97 and others added 22 commits September 9, 2026 10:22
…kirodotdev#9663)

Five full backend runs (92,261 tests each) and five full frontend runs on a
macOS host, from inside a Kiro Crew agent session, with an audit hook that
attributes every host write, spawn, connect and kill to the test that made
it and a per-test census of duration, RSS, threads and descriptors. Zero
backend flakes in 5 x 92k; two frontend flakes; 227 deterministic failures
and four 120-second hangs that every Linux run had been blind to; and host
writes the earlier audits could not see. Every fix carries a test that goes
red when it is reverted, and the classes are written into
testing-conventions "Side effects" so they cannot recur unnoticed.

Host state the suite touched, and what pins it now:

- monkeypatch.undo() in a test body unwound the whole floor (KIROCREW_HOME,
  KIROCREW_PROFILE, KIROCREW_TELEMETRY, the service guard). kirodotdev#9614 moved the
  pins onto the private _floor_monkeypatch; this adds the missing half: the
  monkeypatch fixture is re-declared at the rootdir to depend on it, so the
  floor is set up first and torn down last by construction rather than by
  autouse ordering. test/conftest.py's autouse pins ride the same instance.
- A metric emitted at IMPORT (ToolHookResult.allow() as a default argument)
  built the recorder from the operator's real config before any pin
  existed and exported to the real ~/.kiro/crew/metrics every minute for
  the life of each worker. KIROCREW_TELEMETRY=0 is now pinned for the
  whole process in pytest_configure, and every module whose collection
  builds the recorder is recorded and asserted empty.
- The mc-maint sweep resolved config_dir() when it RAN, after the queuing
  test's pin was gone: 60+ mkdirs of the real home per run, plus a rmtree
  and a marker aimed at it. cleanup_stale_sandbox_profiles takes the home
  its caller resolved, with no default that could reopen the on-pool
  resolution; SessionManager resolves it once at construction.
- Five tests dropped the override to test the default home and let
  config_dir() create the real ~/.kiro/crew, and a resolver-only stand-in
  still rewrote the real ~/.kirocrew.breadcrumb beside it. The teardown
  hook reads paths._resolved_home before any fixture unwinds and fails the
  test after the floor has torn down; the floor wraps the breadcrumb writer
  to refuse while Path.home() is the operator's; each test fakes the host
  home instead of only the resolver. A class- or module-scoped setup runs
  outside the floor and now pins what it resolves itself.
- Fifteen __pycache__ trees per run from imports by path: sys.pycache_prefix
  and PYTHONPYCACHEPREFIX point at ~/.cache/kirocrew/pycache.
- KIROCREW_PROFILE=amazon inherited from the agent session failed 150+
  builtin-app tests closed because the pin lived in test/conftest.py,
  which those testpaths never see. It is a rootdir floor now.

macOS, where the Linux-green suite had 227 failures and four hangs:

- _kill_session closed the PTY master before ending the shell; on
  macOS/BSD close() waits for the reader's outstanding read, so four
  terminal tests hit the 120 s timeout on every run. The process tree is
  hung up (SIGHUP, the one signal an interactive shell honours) and
  terminated before the master is closed; the tests run in under a second.
- Linux-shaped tests, one rule each: the frame recorder's 75 logic tests
  pin its Linux-only ACL gate open; O_TMPFILE prompt tests skip on the
  production capability flag; /dev/fd/N is compared by open+fstat identity;
  unlink() on a directory is EPERM here, so _discard_untracked_files
  recognises it; a simulated O_BINARY bit is derived from the live O_*
  constants (landed in kirodotdev#9632); a --copies venv cannot relocate a
  non-framework shared-lib CPython; the darwin workspace binding's pass_fds
  entry is pinned away in the snapshot-descriptor test; a pinned
  sys.platform needs a pinned start id; and codex-review.yml's two sed
  expressions become one 1,8{} block, because BSD sed opens a numeric range
  only on its exact line.
- test_handlers_system_macos_paths reached 8.8.8.8:80 through _local_ip();
  stubbed at the seam the Linux siblings already use.

Frontend: AutoNudgePopover's watch list is three promise hops past
act(render), so the positive test flaked and every negative was vacuous;
the block now waits for the fetch to be answered. App's startup-video gate
read an interruption latch that an effect sets a commit after the
changelog shows, while changelogDecided lands a microtask later; the gate
reads the live conditions in the same commit too.
…gh sendTurn; drop api.steerChat (kirodotdev#9593)

Chat-core RFC P2, batch C of kirodotdev#9570. The last two hand-rolled dashboard
senders call the transport's `sendTurn` and branch on its receipt, so
the deadline and the six-way classification are the core's:

- `hooks/useSceneInteraction.tsx` (scene agent popover composer): the
  running/idle fork -- `api.steerChat` mid-turn, `api.sendChat` plus a
  local `readSendReceipt` otherwise -- becomes ONE call,
  `sendTurn({ message, slot, steer: !!src?.running })`; `steer` is a flag
  of the same endpoint (kirodotdev#8689), not a different receipt shape.
    refused         -> report on the composer, hand the payload back
    transport-error -> report on the composer, hand the payload back
    unknown         -> composer back to idle, nothing claimed (as before)
    response-late   -> report on the composer with the core's
                       delivery-unconfirmed copy, hand the payload back
                       (NEW: the old path awaited without bound; nothing
                       proves the gateway saw the request, so silence would
                       discard the text -- same policy as ChatPage)
    dispatched/queued -> 'sent' tick + mini-thread echo, as before
  A refused steer used to surface through the outer catch (steerChat
  parsed via `j` and threw on a non-2xx); it is now a `refused` receipt
  with the same user-visible result, and a `{ok:false}` steer inside a
  200 -- which `j` passed through silently -- is now reported too.

- `App.tsx` feature-request send: `refused` -> error row with the
  server's reason; `transport-error` -> error row; `unknown` and
  `response-late` stay silent (the request was or may have been
  accepted; the row is the pill's only signal).

- The composer's failed-send line migrates from a hand-written
  `role="status"` div to `ErrorNotice variant="inline"` (same string),
  `askAgent` off with a No hand-off comment naming the restored draft
  (errors-use-error-notice: this diff touches its condition).

- `api.steerChat` had no remaining caller and is deleted; `sendChat`'s
  comment now describes the steer flag it carries for the transport, and
  `sendChat` runs `checkSessionExpired` on its raw response so a 403
  auth challenge on any transport send keeps the silent-refresh / banner
  recovery the `j`-parsed steer helper had.

Tests: the scene coverage file mocks the transport's wire (`api.sendChat`)
so the REAL classifier runs; it asserts `steer` is forwarded from the
running state, adds a refused-steer case and a deadline (`response-late`)
case. The feature-request test pins that the send rides the transport
(deadline signal on the wire). Dead `steerChat` stubs removed from four
test files. RFC 4.2 row split into the three kirodotdev#9570 batches.

Refs kirodotdev#9570
… view) (kirodotdev#8947)

Selecting text in a Crew Members thread (or a split-view pane) offered Copy
only: AssistantMessage's selection toolbar draws the actions its host hands
it, and ChatPage was the only host passing onQuote/onAsk — quote's
FlyingQuote-into-composer and ask's open-/side-and-seed were ChatPage-local.

Extract both into one chat-core seam, chat-core/composer/selectionActions
(useSelectionQuoteAsk + quoteIntoDraft + seedSideChat), and make ChatPage and
ChatPane both consume it — no second implementation. Hosts differ only in
what they own: the composer draft, and how a Side Chat surface for a slot is
brought on screen (openSideChat). Quote is always offered; Ask exactly when
the host provides an opener (capability by omission, like onOpenFull).

- app-sdk: ChatMessageList/MessageRenderContext gain onQuote/onAsk; the
  default assistant row passes them through.
- ChatPane: wires the seam to its own composer (FlyingQuote lands in the
  pane) and takes an openSideChat prop.
- Split view: SessionGridView threads ChatPage's opener, which re-binds the
  activity panel to the pane's slot (switchSlot) before opening the side tab.
- Members page: the detail drawer gains a Side Chat view bound to the
  member's slot (reuses existing "Side Chat"/"Details" strings — no new copy).
- SideChat: the side-seed event now names its slot; a SideChat bound to
  another slot ignores it, and the seed poll waits for the named slot's
  composer (data-side-chat-slot) rather than any Side Chat's.
- ChatPage: only change is consuming the hook (behaviour unchanged) plus the
  one-prop grid wiring.

Tests (mutation-oriented pins): selectionActions unit tests, ChatPane
selection-actions test, MembersPage Side Chat drawer test, SideChat
slot-aware seed, ChatPage Ask seed regression. Capture harness
website/scripts/capture-members-selection-quote-ask.mjs with three
asserted frames under temp-screenshots/members-selection-quote-ask/.
…irodotdev#9668)

Guard (A) in create_session fails closed when a requested agent is absent
from the session's advertised modes, so the session never silently runs the
backend's own default in its place. But it only ever inspects the agent that
set_mode will activate -- an explicit override, or on KAS the injected
default. On kiro-cli, mode_agent is None for an ordinary session, so the
agent actually chosen by the --agent spawn flag reaches no check at all.
load_session has the same shape: its check reads `agent`, and its only
caller passes `agent=agent or None`.

That agent's spec can fail to load silently. A live probe of kiro-cli
settles what the session/new response says in each case: a spec that loads
is reported as `currentModeId` AND listed in `availableModes`, while a spec
the backend refuses is absent from the list and `currentModeId` names
kiro-cli's own default instead. The refusal is silent -- no JSON-RPC error,
a normal sessionId, the process runs on. Nothing downstream notices: the
set_mode response is never read back, currentModeId is never re-compared,
and mcp_session_report only logs (its docstring forbids reading a missing
report as "not mounted").

The user-visible result is a session with none of Kiro Crew's control plane,
while the global provider mcp.json -- which Kiro Crew pins off only on specs
it writes itself -- stays merged. So a third-party MCP server declared there
keeps working while every Kiro Crew tool the injected prompt names answers
"A tool with the name '<tool>' does not exist". For learn_add that reads to
the user as the agent reporting its memory is unavailable and the lesson was
not saved, when in fact the whole server is absent.

Guard (A2) applies the same check to the spawn-flag agent on both
session-start paths, before set_mode, terminating the session it created so
the backend does not hold an orphan. `currentModeId` is read as proof in
both directions: naming the spawn agent admits, naming anything else fails
closed even when no advertised list came back. The compatibility escape is
narrow -- only a response that names NO current mode falls back to the
advertised list -- so older kiro-cli and the offline fake backend are
unaffected. Scoped to the backend whose argv carries --agent, by a positive
identity test (harness-parity H5).

Six regression tests, mutation-verified: neutering the guard fails the three
fail-closed cases, and admitting a current-mode mismatch fails exactly the
substituted-current-mode test.

Co-authored-by: Zejiang Guo <zejiangg@amazon.com>
Video and unrecognized formats were rejected before download, so an inbound
file the agent could have acted on arrived as a note instead of bytes. Keep
them as byte-identical temporary files under a 50 MB cap, hand the agent the
local path plus the original name, type and size, and reuse the existing
per-turn cleanup ownership. Opaque bytes are never parsed or executed
automatically.

A sender picks filename and mimetype independently, so an opaque file could
arrive named "photo.png" while declaring application/octet-stream. The ACP
encoder types a prompt path by suffix alone, so such a path would reach the
image sink without the content-signature check the IMAGE branch enforces --
emitted as image/png on the strength of a sender-supplied name. An inlineable
image suffix is therefore stripped from the temporary path before ownership
transfers, mirroring the retype the IMAGE branch already performs, and a
rename that fails drops the attachment rather than emitting the original path.
A contract test pins the suffix set against the encoder's own table.

Co-authored-by: Jindong Hu <hjindong@amazon.com>
…chooser (kirodotdev#6737)

A neutral, bookmarkable session list at /sessions inside the main dashboard
shell (nav rail + top bar stay available). Rows navigate to the full
/chat/<key> experience; unlike bare /chat the page never auto-selects or
auto-creates a session. Full-bleed rows on phone; schedule-style inset with
bordered group cards on desktop. Filter chips (All / Unread / status tags in
use), search, recency groups (Today / Yesterday / Earlier), New session.

Original work by Helena Stafford (@helenastafford).

Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
…lt-free (kirodotdev#9673)

Two reds on `Backend Tests (Windows) (4)`. Only one is a Windows fact.

`work_ledger._read_json_record` reads with a bare `read_text`, and a STRICT read
there is lock-free across writers: `_refuse_if_worker_holds_open_item` reads the
prior item under the WORKER's binding lock while that item's own conductor may be
replacing it under a different item lock. On Windows a read of a file another
handle holds open for write raises `PermissionError`, so one correct concurrent
writer turns a strict read into a bare `OSError`. The guard catches only
`WorkLedgerError`, so it escapes `apply_conductor_action`, and the dashboard route
maps `OSError` to a 503 `ledger_write_failed` "try again" -- telling a conductor to
retry a binding that is legitimately taken until the item closes. A permanent
refusal reported as transient is a product defect, so the fix is in the store:
route the read through `atomic_write.read_bytes_with_retry`, the read-side twin of
`replace_with_retry` that `members.py` already uses for this exact window. A
`PermissionError` that outlives the bounded retry still escapes, so the
fail-closed contract holds; on POSIX the helper re-raises on the first attempt.

The second red is not Windows-specific at all.
`test_reconcile_and_publish._finding` mints each PR url from
`abs(hash(fp)) % 9000 + 1`. `hash()` on a str is salted per interpreter process,
so roughly one process in 9000 gives two fingerprints the same url; the sweep then
skips both and the test reads `assert [] == ['aa']`. Each xdist shard is its own
process drawing its own salt, which is the whole reason it looks like one shard is
special. Reproduced on Linux with `PYTHONHASHSEED=17494`, where `aa` and `bb` both
map to `pull/1344`. A sha256 digest is salt-free, so the mapping is identical in
every process.

Tests: a new `test_a_contended_item_read_still_refuses_with_already_bound` drives
the guard through `windows_sim.read_sharing_violation` and asserts the refusal
stays `already_bound`; reverting only the retry line makes it fail with the
injected `PermissionError`. The four existing fail-closed tests move their fault
from `Path.read_text` to `Path.read_bytes` through one shared `_fault_record_read`
helper, since that is the call the record reader makes; their assertions are
unchanged and still pass on POSIX, where the helper does not retry.
…9739)

glab api pipes JSON write bodies over stdin with --input - but sets no
Content-Type header. Strict self-hosted GitLab instances reject such a
request with HTTP 415 Unsupported Media Type, so Issue Radar write
operations (label add/remove, state changes) fail there. Add
--header "Content-Type: application/json" to the glab argv whenever a
body is present. Read paths without a body are unchanged, and the
GitHub transport is untouched.

Closes kirodotdev#9723
…v#9500)

A security fix that makes the proof of concept stop reproducing has done
half a job. The other half is the half a security change actually gets
rejected for: the deny fence grows a rule that also refuses `gh pr view
--json`, or a guard is written against one host's spelling of a path, and
the tool the fix protected becomes one nobody can use.

This adds the corpus of legitimate operations that must survive a fix, and
the gate that re-checks them.

- ledger.py gains a fifth table, `golden_paths`, behind an additive
  migration: every DDL statement is CREATE ... IF NOT EXISTS, so an
  existing v1 database gains the table on the same pass and opens, and the
  version bump is the last write -- an interrupted upgrade leaves the
  version behind the tables, which retries, rather than ahead of them. The
  bump is an UPDATE (the version row is a singleton) guarded by `<`, so a
  database written by a newer checkout is left alone rather than walked
  backwards. Every existing function signature is unchanged.

  A corpus import is ONE transaction, so an interrupted one commits
  nothing: a subset is a fence nobody chose and a silent one, since the
  rows that never landed are invisible. That is why the import does not
  call `add_golden_path` in a loop -- that function owns a transaction per
  row -- and why the INSERT is split into a helper owning none.
  `source_finding_id` rejects a bool explicitly, because `bool` subclasses
  `int` and SQLite stores `true` as 1, which would silently attribute a
  golden path to finding 1. Every text field that is present must BE a
  JSON string: a list, number or null is refused, never str()-coerced or
  defaulted, because "['gh', 'pr']" stored as a golden path makes the gate
  classify a command nobody runs, and "platform": null widened to "any"
  would gate every host with a row meant for one.

  Rows carry the same propose/approve split as lessons, and approval is
  write-once so the reviewer who admitted an operation stays on the
  record. The table is the EDITING SURFACE, not the gate: an audit
  proposes a row, a human approves it, and it reaches the gate when it
  lands in the committed export through review. The CLI has three verbs
  -- propose, approve, import -- mirroring the propose/approve pair the
  RFC gives lessons and the committed-file relationship it gives
  rules-of-engagement.json; no verb records one active, attributed row
  from the command line, since that would be the approval without its
  write-once record. There is deliberately NO
  CLI verb that retires a golden path: the RFC gates deactivation exactly
  like activation, and the flip stays the same human row edit rules and
  lessons already use. Nothing reads the table as a gate, so the ledger
  ships no host-filtered reader for it.

- verify_fix.py is the two-step gate. Step 1 runs verify_finding.py by
  path -- never a copy of its judgement -- and requires `rejected`. Step 2
  classifies every `shell` row of the COMMITTED golden-paths.json with the
  tool gate's WHOLE deny composite -- the four checks hooks.on_tool_call applies to a shell
  command, in its order (path fence, sensitive-command tier, exfiltration
  auditor, deny-rule catalog), the same tier table scripts/deny_diff.py
  declares -- and names the tier that refused. Measuring the catalog alone
  would go green on a fix that tightened any of the other three. A tree
  missing a tier is coverage lost and reported unavailable, not skipped.
  A corpus file that is absent, does not load, or holds no row is 20, not
  a pass: zero rows checked would fold to holds by construction, which is
  the vacuous green a broken installation would report on every fix --
  and the refusal scripts/deny_diff.py already makes of an empty corpus.

  The gate reads the committed file and NOT the ledger's table, as the RFC
  rules ("both gates read the file and nothing else"). The ledger is
  per-host and invisible to CI, so a gate that read it could not be
  enforced where it is declared blocking; and the table is mutable by
  anything that can reach the database, so a gate that read it could be
  steered by a row flip -- retire the one row the fix broke, and the gate
  goes green with the fix unchanged. The file is read from beside the
  skill, not from the worktree under review, so the change being judged
  cannot rewrite the gate that judges it. And no ARGUMENT can shrink the
  corpus: there is no flag naming another corpus file or another
  platform -- the file is the one beside the script and the platform is
  the host's own -- because a caller who could name either could name a
  smaller check. Rows are named by their position in the file. The file
  is validated by ledger.py's own loader, the same check
  import-golden-paths applies, so the gate and the import cannot judge one
  row by two rules -- and the loader accepts ONE shape, the object the
  export is written in, so a second shape nothing writes is not a corpus.

  The sibling ships in this same bundle, so an absent one is a broken
  installation rather than a state to accommodate: still exit 20, because
  a proof that was not re-run says nothing about the fix, and checked here
  rather than left to the spawn, since the interpreter exists and a
  nonexistent script argument makes the child exit 2 -- the verifier's own
  code for "I rejected your input". The sibling `ledger.py` is the same
  class: one that is absent or will not import is exit 20 through the same
  JSON payload, never a traceback and exit 1, which is no verdict in the
  contract. One printer, `emit()`, owns the payload shape and the stderr
  lines for every path out of `main()`. Step 2 re-classifies every `shell`
  golden path in the export whose platform matches the host against the
  FIXED code; none may be refused.

  NOTHING out of the corpus is executed, and that rule decides the design.
  A row is text in a JSON file, and the same text once imported sits in a
  table whose CLI ledger.py states plainly is not an authentication
  boundary -- `--approved-by` is an unverified caller assertion -- so a
  row is untrusted text written by whoever could edit the file or reach
  the database. Every other consumer only READS such a row; running one as
  argv would turn a file edit into command execution with the operator's
  access, which no containment fixes because the escalation is in treating
  the row as permission. So `shell` rows are CHECKED (classified, never run) and
  `flow`/`cron` rows are RECORDED and reported for a human to exercise:
  an MCP tool has no command line, a chat turn needs a gateway, and firing
  a schedule has effects no deadline bounds. Parsing a schedule here would
  settle nothing either -- a parse in this process never consults the
  fixed code, so its answer is a constant no fix can change -- so the
  corpus's own well-formedness is asserted in the test suite, at review
  time, where a corpus-authoring mistake belongs.

  Exit codes are the interface -- 0 holds, 10 the proof still reproduces,
  30 a golden path is refused, 20 unverifiable -- and precedence is
  10 > 30 > 20 > 0. The load-bearing property is the last one: 0 is
  unreachable while any check this script OWNS went unsettled, because an
  unclassified golden path is not a permitted one. A human row never moves
  the exit code, because it was never claimed as this script's check.

  The fence is read in a CHILD process with the worktree's own `src`
  leading PYTHONPATH, because the point is to classify against the FIXED
  code: an in-process import would bind whatever copy of the package the
  interpreter already loaded, which for a test runner inside the
  repository is the unfixed one. A leading PYTHONPATH entry is a
  preference, not a guarantee -- a checkout without kiro_crew/security
  would import the INSTALLED package -- so the probe proves where
  kiro_crew.security came from and reports the composite unavailable
  (exit 20) unless that file resolves beneath the worktree's src. A fence
  borrowed from somewhere else is not a fence that agreed. The probe's payload is shape-checked
  before any field is read off it, so a malformed answer is 20 rather than
  a traceback exiting outside the contract.

  Two containment details are deliberate. The ledger path is resolved ONCE
  and passed to the verifier child explicitly -- the only thing this
  script wants the ledger for: the child's HOME is the worktree and the
  ledger's default path is HOME-relative, so a child left to its own
  default would read a different database. And the deadline kills the
  direct child only, matching verify_finding: a process-tree kill has no
  portable spelling in the standard library, and a verifier whose own
  teardown works on one host would be the platform lock-in this corpus
  exists to catch. `--worktree` must be a git checkout, which is the
  blast-radius bound the re-run proof depends on.

- golden-paths.json is the committed export the gate reads: 37 rows, each
  with a reason. scripts/deny_diff.py's usage line and the fixture's
  _comment name this file and the adopting change that retires the
  fixture (kirodotdev#9678); wiring the gate into the fixer lane is kirodotdev#9707. The rows
  cover read-only gh, git read/write/publish, the sanctioned test
  invocation in both host spellings, the lint and type gates, the publish
  preflight, the monitor-loop MCP tools, chat-start-one-turn via the
  offline stub-ACP harness, and three shipped cron shapes. Two rows are
  the read-only gh shapes that were false-positive refused while this was
  being designed: two `gh pr view --json` calls joined by `;`, and a PR
  read combined with a run list.

Tests

- test_security_conductor_verify_fix.py (73): the verdict ladder end to
  end, driven as a subprocess with siblings staged beside it and the
  corpus written one directory up, so the absent-verifier and absent-corpus
  cases are testable at all; that the gate reads the committed file and
  not the ledger, from both sides (a row only in the ledger does not gate;
  a row retired in the ledger still does) and from the worktree (a
  checkout shipping an emptier corpus is still judged by the skill's own;
  no flag names another corpus or the other host); that a worktree without
  the package does not borrow the installed fence, and that a fence under a
  symlinked src is still the worktree's; that no corpus row is executed,
  proved by a witness file each row would create if it ran, plus a
  structural guard that the golden-path walk carries no spawn at all and
  only two call sites spawn anything; the platform filter in both
  directions; seven malformed probe payloads each pinned to the specific
  guard it trips; the ledger path handed to the child; and one class that
  asks the REAL `is_denied` about every shipped shell row, which turns the
  corpus into a live regression gate at PR time.
- test_security_conductor_ledger.py: the new table's columns, its identity
  (kind + command + platform, each part proved load-bearing), write-once
  approval, retirement keeping its approver, corpus import that is
  all-or-nothing on both validation and interruption, a racing identity
  collision counted as skipped rather than fatal, and the v1 migration.
- Smoke-tested end to end against the real sibling verifier and the real
  deny fence, with no stubs: a finding whose PoC no longer reproduces plus
  a permitted `gh pr view --json; gh run list` row returns 0 and reports
  the flow and cron rows for a human, and adding one genuinely refused row
  returns 30 naming the rule that ate it.
- Red-before proven by reverting forty guards one at a time, including
  the single accepted corpus shape, the string-typed text fields,
  the file-not-ledger read, the skill's-own-copy read, the absence of a
  corpus or platform flag, the fence-provenance check and its prefix
  boundary, the absence of a one-step add verb, the unreadable-,
  unloadable- and empty-corpus verdicts, the four-tier composite, the missing-tier
  verdict, the absence of a retirement verb,
  both exit-30 and exit-20 paths, the human-corpus separation, the
  structural no-spawn guard, the platform filter, the migration bump, the
  single import transaction, the bool rejection, and the live-fence gate;
  every one turns its test red.

Refs kirodotdev#9195, kirodotdev#9332

Co-authored-by: Joe Guo <zejiangg@amazon.com>
…t-model setting (kirodotdev#9099)

A partition that does not serve the `auto` sentinel rejects every session that
starts on it with "Your account does not have access to model 'auto'". The
generic entitlement message then advised "set agent.model to 'auto'", which is
the value that just failed, and the row offered Continue, which replays the
identical rejection. Reported from a region whose account is served only
gpt/deepseek/minimax/glm/qwen models (0.4.1).

- `_format_acp_error`: when the rejected id IS `auto`, say the automatic choice
  is not offered in this region and route the user to the session model picker
  and to Settings -> Chat -> Default Model (plus any per-agent pin). The non-auto
  branch keeps its wording.
- `chat_runner`: the terminal error row carries `meta.kind = model_unentitled`
  with `rejected_model` and `advertised`, decided from the exception's tags
  (same evidence the formatter used), never from the prose.
- `ErrorCard`: a `model_unentitled` row offers "Choose a model" (opens the
  composer's model picker) and "Change default model" (deep link to the
  chat.default-model setting) instead of Continue. Panes without a picker render
  the row as prose. Strings added to en.json + pseudolocale.

No automatic model substitution: the user picks, and the wire is unchanged.

Tests: new test_model_unentitled_meta.py (6), formatter wording test
(mutation-verified: fails on the pre-change formatter), ErrorCard tests (3).

Co-authored-by: Zejiang Guo <zejiangg@amazon.com>
kirodotdev#9751)

`_capped_spill` re-imported `threading` inside the function, which the
module already imports unconditionally at line 24. The inner statement
re-bound the same `sys.modules` singleton, so removing it changes nothing
at runtime and removes the false hint that this function needs its own
import. No test patches `threading` on this module, so the local-to-global
name promotion is unobservable.

Two provably-equivalent structural edits alongside it:

- `_await_inflight_spawn`'s tail tested `cur is not None` twice and
  re-read `.starting` to derive what the preceding arm's negation already
  proved. It now returns early on `cur is None`, so each later test reads
  once. `.starting` is written only at construction, and both reads were
  inside the same `_lock` hold, so the single read cannot differ.
- `_start_app_backend_body` guarded `manifest.backend` against None when
  computing `backend_type`, having already dereferenced
  `manifest.backend.entryPoint` unguarded 326 lines earlier in the same
  function. `AppManifest.backend` is a non-Optional `BackendConfig` with
  no `__bool__`, so the else arm was unreachable.

The rest is comment hygiene in the same file, per AGENTS.md. A block above
`_PID_ANCESTRY_MAX_DEPTH` described "consecutive alive polls" that no
constant holds and `_survived_spawn` does not count; it is gone, and the
surviving block above those constants now states when a healthy backend
actually pays the full window (only where listener ownership cannot be
proven) instead of claiming it never does. Five docstring passages
narrated superseded code and now state current behaviour in present tense,
keeping the rationale. Two named the wrong thing: `_wait_for_pids` cited
`kill_pid` where the caller uses `kill_pid_pinned`, and
`_start_backends_concurrently` claimed boot costs one grace window
regardless of app count, which ignores the `_BOOT_SPAWN_MAX_WORKERS` cap.

`comment-history-baseline.json` drops this file from 7 markers to 6, which
`check_comment_history.py --write-baseline` records; the gate is a downward
ratchet and refuses a diff that retires a marker without lowering it. The
six remaining hits are present-tense statements about runtime state ("a
record that is no longer the tracked one"), not change history, so they
stay listed.
Adds the umbrella spec the two existing monitoring specs sit under, so a
new monitored kind has a contract to implement rather than an existing
watch to copy.

Seven layers, one owner each, and six of them never learn what is being
watched. The consequential contracts:

- The probe signature is PLURAL from day one. A per-subject interface
  cannot be batched later without changing every implementation and every
  caller, and the difference is roughly 150 process invocations against
  one query for fifty subjects. A probe that cannot batch loops internally
  so the caller never encodes the difference.
- An observation is a NAMED entry with a severity and a reset scope, never
  a bare fingerprint. A hash cannot be deduplicated per condition,
  coalesced with a sibling, or re-asserted, because nothing can tell
  whether two hashes describe the same condition.
- The decision layer is a pure function whose clock arrives as a value,
  and it is level-triggered. Edge triggering loses any condition that
  stayed true across a wake that did not happen. The re-alert window makes
  that affordable and the budget makes it safe: a notification pipeline
  aimed at humans needs no token budget because a paged human self-limits.
- Persisted state holds delivery bookkeeping only, versioned with a
  migration per bump. Subject state belongs in the disposable evidence
  file.
- An out-of-session driver is a detector, never a reactor. A cron turn has
  no owning slot, so its tool calls hit deny-by-default and time out while
  the job still records healthy.

Also records the rules that must be enforced by code rather than by prose,
including one the two current implementations already disagree on: the
status tool collapses superseded check attempts to the newest per identity
while the structured provider treats each row independently and maps
CANCELLED to failed, so it can wake on a failure that no longer exists.

Status is per layer, verified at 2f9ed97, because most of this is the
target rather than a description of what runs today.
…b Crew summary (kirodotdev#9437)

The Members page's right column was a closable DetailPanel drawer — a
hand-built lookalike of the chat page's right column. It is now the chat
page's own tabbed SidePanel, permanent on wide windows (no close control,
no header toggle), whose first tab is the member's Crew summary carrying
what the drawer showed (identity + status, counters, driving sessions,
auto patrol, recent activity, wake sources, configuration, memory note,
edit exits). The + menu is the chat panel's (Files / Artifacts / Terminal
/ Browser / Side chat …) against the member's DM slot; the strip is
bucketed per member so it follows the roster selection.

SidePanel gains `leadingTab` (a host-owned, non-closable, non-draggable
tab ahead of the pinned block whose body the host renders), an optional
`onClose` (absent ⇒ no close control, Escape inert) and `extraReserveW`
(a sibling column's live width kept clear on drag). `usePanelTabs` takes
`opts.leadingId` so a fresh strip opens on the leading tab and an emptied
strip falls back to it. File / artifact open and file save move out of
useChatPageResourcesController into the shared `usePanelDocumentActions`
so both hosts run one implementation. Narrow windows (roster + thread +
panel cannot seat) keep the panel as an overlay the header button opens.

Closes kirodotdev#9432

Review round 1 (GPT): no agent hand-off on the panel read-error notice (the ChatPane below holds the DM draft as unsaved local state); Side chat withheld on this page until its composer draft persists or SidePanel keeps its body mounted (MEMBERS_WITHHELD_VIEWS, test-pinned).

Review round 2 (GPT): the panel binds to the member slot record only from the CURRENT snapshot (slotsLoaded) and only when it is the member's own (mode === member), so a record left over across a reconnect can never root Files / Terminal in a pre-restart project (test-pinned).

Design round: the unconfirmed-window withheld set is derived from VIEW_DATA_SOURCE (every classified view + terminal + app) instead of enumerated, so a new ViewKind is withheld there by construction; test pins it against the classification.

Rebased over kirodotdev#8947 (Quote / Ask on selected text): the drawer-hosted Side Chat main added is replaced by the panel's own Side tab — `openMemberSideChat` focuses it (revealing the overlay on narrow windows) for the confirmed slot only — and Side chat is offered again, since kirodotdev#8947 moved its composer draft into the per-slot chat-core store (the unmount-loss reason it was withheld for is gone). MembersPage.sideChat.test.tsx rewritten for the panel.

Fix-up: the Side tab is a dynamic tab (title as text, no aria-label) so the sideChat tests read it by accessible name; the drawer-only `side_chat_draft_waiting` key is removed from every catalog (dead-keys ratchet).
kirodotdev#9415)

No shipped seed scenario could paginate. The slot-detail fast path
answers has_more=false unless a session has size-rotated archive/
segments, and rotation only fires once a transcript outgrows a 10 MiB
budget, so every scenario built from a handful of messages leaves "load
earlier" structurally unreachable and a paging trace cannot be performed
through the real routes.

sessions-long-history seeds that state: one pinned session whose oldest
60 rows sit below a real rotation boundary. The initial slot load returns
the 8 live rows with has_more=true and next_before=60, and paging with
that cursor returns rows out of the archive.

Both shipped files are real ConversationLog output. The segment is
_maybe_rotate output at the production budgets, and the live transcript
is compaction output holding rows append wrote, so the fixture cannot
encode a rotation shape the writer does not produce. Only the rotate
segment and the live transcript are kept; the ~10 MiB compact segment the
recipe produces is discarded, which is the state a home reaches once
archive retention reclaims it. That keeps the fixture at ~20 KB of
package data, inside the 64 KB per-fixture cap.

The contract test pins the design point through the real readers and
pins the shipped segment's header and row shape against a rotation
driven in-test, so a change to the rotation format turns red instead of
leaving the fixture describing a shape the product no longer writes.
…ks (kirodotdev#8784)

Chat code blocks pin their copy/edit buttons to the top-right corner
only. Once a block's rendered content grows past a fixed height
threshold, scrolling to grab those buttons costs a trip back to the
top -- exactly for the blocks most worth copying (long generated
configs, multi-file snippets).

Measure the block's content height with the existing
useMeasuredHeight hook and repeat the same action row (copy, plus any
caller-supplied headerActions such as the edit pencil) in a footer
once the block exceeds that threshold. The row markup is factored into
a small CodeBlockActions component shared by both call sites, so the
header and footer duplicate stay visually identical without a
copy-pasted JSX block.

Review findings addressed across this PR:

- max-two-buttons-per-row (blocking): the header's Run + Edit + Copy is
  pre-existing (legacy status), but the footer is a NEW row, so
  mirroring the header there put 3 siblings in a fresh row. CodeBlock
  now takes a separate optional footerActions prop (defaults to
  headerActions); EditableCodeBlock passes a trimmed Edit-only set to
  the footer, keeping Run header-only.
- Footer copy reported success even when the clipboard write failed
  (errors-use-error-notice, blocking): copy() now awaits copyCode and
  only confirms on a true result, matching the established pattern in
  TailnetMobileCard.
- Footer-triggered edit collapsed the block out from under the click
  (the max-h-[480px] editor replacing a much taller block with no
  scroll compensation). Fixed with scrollIntoView, gated on the
  wrapper's top actually being above the viewport so a mid-viewport
  HEADER edit doesn't also yank the page up for no reason the click
  gave it.
- Inclusive Language (woke): "grandfathered" -> "legacy status".

Also: the footer's border was always painted, leaving a permanently
empty strip under every tall block at rest. It's border-transparent
until the same hover/focus reveal that shows the buttons, so the row
still reserves its height but paints nothing until there's something
to show.

Screenshots captured from an isolated CodeBlock render (short block vs.
a 30-line tall block) with the action row hovered, committed under the
repo-root temp-screenshots/ convention.

Closes kirodotdev#8227
…choice can change later (kirodotdev#8497)

The Agent Template dropdown is the create modal's highest-stakes-looking
field: first-time users stall on it, assuming the pick is permanent. A
one-line note under the dropdown says the template can be switched anytime
after creating. Create-only via TemplateField's editLaterNote prop — the
editor renders the same field without it, since there the editable fields
are themselves the answer. Localized in all 13 catalogs.

Split out of kirodotdev#8497's original combined diff at the maintainer's request;
the Session Color removal now rides its own PR.
…mated) (kirodotdev#9493)

Installed theme packs can supply their own chat-footer loader art via
loader/*.png|webp|gif|svg. One image renders on its own; 2-8 are cycled by
the stock carousel. Animated WebP/APNG/GIF and animated SVG self-animate in
the <img>, so a pack can ship a single fully-authored loop.

SVG rides the same <img>-secure-mode asset path as logo.svg (served under
_THEME_ASSET_CSP: default-src 'none'; sandbox), so scripts are disabled and
external references are not fetched by the browser -- no HTML-serving route,
no content sanitizer, no egress surface.
Introduce the project-coordination grouping tag on _ChatSlot, plumbed
through every persistence site so it round-trips save->rehydrate:
- _ChatSlot.__slots__ + __init__ default
- SLOT_OWNED_META_KEYS (flows into derived ROWS_ONLY_DEFERRED_META_KEYS)
- both metadata write sites (chat_persistence)
- all four rehydrate sites (chat_persistence x2, channel_slots, chat_handlers)
- fork copy (chat_fork)
- cleared + rollback-restored on workspace switch (chat_handlers)

Field only; store/tagging/collision/panel follow in later commits.
Cover the properties the pre-merge gate flagged unverified:
- save->rehydrate via restore_recent_sessions (primary read path)
- write round-trip via ConversationLog.update_metadata/get_metadata
- clearable-by-absence (absent metadata -> "")
- str() coercion of a non-string persisted value
- SLOT_OWNED_META_KEYS membership + derived ROWS_ONLY_DEFERRED_META_KEYS
- fork inheritance of the tag

7 tests, green under .venv pytest.
)

Record both gates against the SHA per ship-it: panel GO (GPT/Opus/Design
PASS, First-Principles CONCERNS=taste) + crew GO (all axes; Tests HIGH
closed by round-trip tests). Notes adjudications and accepted follow-ups.
Add ProjectStore — the {id, name, repos[]} record table a session's
project_group_id points at. Mirrors the crons.json durability pattern
(advisory flock + content-digest sync + atomic tmp->rename write) without
the cron scheduler machinery:
- create idempotent BY ID (names are not unique)
- concurrent-create TOCTOU closed (sync + in-lock existence check)
- delete ALWAYS proceeds (no cross-store refuse-if-nonempty; dangling id
  is a reader concern per design 4.1)
- repos[] auto-derived rollup: observe_repo appends deduped, append-only
- malformed file/records degrade to empty, logged, never fatal; a present
  but non-list 'projects' value is normalized (no crash on {"projects":null})

Reads are cache-only (documented invariant); only the write path picks up
an external change. Contract kept to {id,name,repos[]} + create/delete/
observe — no rename/exists/created_at surface (unused). 16 store tests green.
@github-actions

Copy link
Copy Markdown

GPT 5.6 Review — ⚠️ review incomplete

GPT 5.6 did not produce a complete verdict for b04ccbad41d97053d37c5b2ed9b9e2cfbc8580a5; inspect the workflow logs and re-run it.

This comment is updated in place on each push.

See the GPT 5.6 Review job logs; this commit has no completed GPT verdict.

False positive or not applicable? A repository writer can comment:
/ai-review override gpt b04ccbad41d97053d37c5b2ed9b9e2cfbc8580a5: <one-sentence reason>

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 10, 2026
@SaRedfiche SaRedfiche closed this Sep 10, 2026
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Sep 10, 2026
SaRedfiche pushed a commit that referenced this pull request Sep 24, 2026
…irodotdev#12915)

* fix(chat): wire the paste-block sidecar into ChatPane and SideChat

ChatInput collapses a large paste into a `[ Paste #N · M lines ]` token
only when its host passes `onPasteBlocksChange`. ChatPage did; the split
view / member DM pane (ChatPane) and the side panel (SideChat) rendered
the composer without it, so a big paste stayed raw text there.

Both hosts now keep the blocks with the same lifetime as their text:

- ChatPane: pane-local `pasteBlocks` state, parked and restored with the
  text through chatPaneDrafts (a third sessionStorage store), expanded
  for the model in doSend and doSteer, kept as tokens on the bubble with
  `meta.pastes` + saveStoredPaste so the row renders a chip, handed back
  with the text on a refused send (carryPastes re-numbers a colliding
  block), and put back EXPANDED by a queue-card cancel because the stash
  carries no blocks.
- SideChat: blocks live in the in-memory side-chat draft entry next to
  the text (a remount restores both; neither outlives the page, matching
  the store's documented intent), expanded in `send` on both the send and
  steer branch, pruned whenever the text is written.

Tests: page-level ChatPane and SideChat scenes (paste, pill, send,
expanded wire text, cleared composer), a rebind and a refused-send
restore for the pane, a remount for the side panel, and unit coverage of
the pane store's new paste field + carryPastes. Capture harness entry +
script for the PR evidence.

Closes kirodotdev#11337

* fix(chat): park pane drafts as one value; carry pastes with a synced ref

Review rounds 1-2 on the sidecar wiring:

- The pane store held text, files and paste blocks under three keys with
  independent byte caps, so an over-budget slot could lose its block while
  the text kept the token — a reload then sent the token literally. The
  parked draft is now ONE stored value (`mc-pane-drafts`: {text, files,
  pastes}) under one cap: a slot is evicted whole or not at all. Drafts a
  tab parked under the two-store layout (a text string per slot, paths
  under `mc-pane-file-drafts`) are folded into the unified shape on the
  first read after a reload, so an upgrade mid-session loses nothing.
- Two recoveries landing in one React batch both read the render-time
  `pasteBlocksRef`, so the second `setPasteBlocks` dropped the first's
  blocks while both tokens stayed in the text. `carryIntoComposer`
  advances the ref synchronously per recovery before scheduling state,
  so the second carries on top of the first; the rebind path advances it
  the same way.
- `carryPastes` moves to pasteTokens.ts as the one owner of the
  kept-ids -> remapCarriedBlocks -> concat rule; ChatPage's two inline
  spellings now call it.
- `readSideChatPastes` had no product consumer; removed. The SideChat
  tests observe block state through the composer and the wire instead
  (a fresh paste numbers from #1 again once the blocks are gone).
- SideChat's too-long message counts the EXPANDED question; when a
  collapsed paste is part of it the message now says so
  (`question_too_long_with_paste`, all catalogs), since the composer
  visibly holds one line and a pill.

Tests: a two-refusals-in-one-batch scene on the pane (fails without the
ref sync: two `#1` tokens), whole-slot eviction, legacy fold and
malformed-value drop on the store, the paste-aware too-long message.
SaRedfiche pushed a commit that referenced this pull request Oct 5, 2026
…ger off (kirodotdev#15851)

## Problem

A Linux system install writes a gateway unit that bakes
`XDG_RUNTIME_DIR=/run/user/<uid>` and `DBUS_SESSION_BUS_ADDRESS` into its
environment, so every agent runtime scope is created under that account's
`user@<uid>.service`. With `Linger=no` logind stops the account's manager when
its last login session ends, and systemd takes every runtime down with it:
in-flight turns and subagents get SIGTERM and new spawns fail with "Failed to
connect to bus" until the account logs back in. The gateway, running under the
system manager, stays up and reports nothing, so the install gives no sign that
agent work needs linger to survive a disconnect.

## Why it matters

The system-scope install is the path a user picks precisely so the gateway
outlives their SSH sessions. Without linger that promise is silently broken for
the agent runtimes, and `kirocrew service install` prints nothing about it even
though the user-scope remedy already documents `loginctl enable-linger`.

## Not a goal

The architectural fix for defect #1/#3/#4 in the issue -- running the runtime
scopes under the system manager vs. making linger a hard requirement, plus the
disconnect fallback and reconnect recovery -- is a placement decision the
maintainer routed to needs-investigation. This change implements only the
install-time warning the maintainer named as a small, separable piece. It never
changes the install outcome.

## What changed

- `service/linux.py`: add `linger_warning_for_service_account()` (and a
  `_linger_enabled` probe reading `loginctl show-user <user> -p Linger`) that
  returns an operator warning when linger is off for the resolved service
  account, naming the `sudo loginctl enable-linger <user>` fix. Returns None
  when linger is on or cannot be determined, so the install reports nothing it
  is not sure of.
- `service/controller.py`: print that warning after a successful system
  install, next to the AppArmor and headless-auth notices. Non-fatal.

## Tests

- `test_linger_warning_fires_when_linger_is_off` / `_silent_when_linger_is_on`
  / `_silent_when_undeterminable` cover the probe's three outcomes.
- `test_install_systemd_prints_linger_warning` / `_silent_when_linger_ok` cover
  the controller wiring.
- Full `test_service.py` (413 passed, 3 skipped), black/isort/flake8/mypy and
  the comment-history gate all clean.

Fixes kirodotdev#15678

Co-authored-by: kirocrew-agent <agent@kirocrew.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.