Self-Learning, Cron & Dashboard Modules
September 21, 2026 · View on GitHub
Overview
Phase 5 adds self-learning from corrections, scheduled cron jobs, and a web dashboard. Member roster and rules validation reuse one off-loop configuration snapshot for stable slug resolution and collision checks; per-member lookups do not reload configuration on the request loop.
The Kiro credits readout opens a privacy-first account panel backed by the
existing /api/sessions/usage authority. Account identity is exposed only when
the billing ARN and whoami ARN are coupled; the UI never derives identity from
the readiness latch and introduces no parallel account endpoint. Email is blurred
by default and the local visibility choice persists. Every bounded promotional
breakdown returned by the free billing API is retained, displayed individually,
and pooled into the compact readout. The paid kiro-cli /usage turn remains
fallback-only, and its two known bonus formats produce the same list shape.
When the local session transcript directory does not exist, /api/usage/kiro
returns the same complete zero-valued session statistics as an empty directory,
including today, this_week, this_month, and an empty daily_history.
Reading usage does not create the directory, and available billing is preserved.
The normal usage cache TTL applies; later session files are counted on refresh.
Other directory read failures report the reason ALONGSIDE that same complete
zero-valued shape rather than instead of it: the response carries error and
code next to the statistics, is still not cached, and the client raises the
server's message rather than reading a period key that is not there. The zeros
on that path are a shape, not a measurement, which is what the error field
says.
The Overview usage summary and Usage report share a per-provider browser-memory cache for the dashboard lifetime. Opening either view shows the last successful report immediately. Data is fresh for five minutes; an older report refreshes asynchronously on return. While either view is mounted, the query refreshes every five minutes, including in a background browser tab. Leaving both views stops polling but does not discard the report. A failed refresh identifies the figures as the last values read and shows the error alongside them; the next refresh can recover without clearing the report. A provider without usage support shows neutral status text, not an error. No report is persisted to disk, and the top-bar credit readout keeps its separate billing refresh policy.
Gateway restart
The dashboard restart endpoint and successful update applies share
handlers/updates.py::_restart_gateway. It coalesces duplicate requests and
validates the composed stable launcher before saving history or closing sessions.
An invalid explicit launcher reports an error without draining; no launcher keeps
the existing Python/managed-venv path. The launcher receives the original CLI
arguments and inherited instance environment, and owns rebuilding version-specific
imports. See platform-context for
the CPP contract. Update authorization and command-provider precedence are unchanged.
Self-Learning (learn.py)
Global Memory V1 retains the background LLM contradiction sweep after a successful lesson write. Member Memory V2 never schedules that sweep, and the helper refuses V2 even when called directly: inferred contradictions cannot delete private rules. Distinct rules remain available for explicit owner review; same-identity conflict proposals and evidence-backed owner correction/undo are unchanged.
Detects user corrections (e.g. "use X instead of Y", "remember that X", "never use X") and stores them in ~/.kiro/crew/lessons.jsonl. Categories: tool, preference, knowledge. Injected into LLM context as a [Learned corrections] block with complete applicable rules; startup does not trim uncertain rules by recency. Detection runs after each ACP response.
The JSONL file is per-target, so the three /api/lessons routes carry a store dimension. ~/.kiro/crew/lessons.jsonl is the DEFAULT store's file; a caller bound to a named memory store reads and writes memory_stores/<name>/lessons.jsonl instead, and _lesson_jsonl_store picks between them by the caller's BINDING, never by which file holds rows. LessonStore.path is the resolver — construct the store and read it, never compose <dir>/lessons.jsonl, or the two fall onto different files while both look correct. Full rule, including why keying on population is a data leak: memory-skills-hooks. Because a silo-bound crew's corrections land in that file exactly when the silo has no memory.db, this tier is the one a vector-only injection audit is blind to; security.scan_memory scans every store's lessons file for that reason — see security.
Named-store lessons require a middleware-verified internal request or the dashboard owner, via resolve_lesson_memory_store. A session key alone does not authorize reading or mutating that session's silo. Non-owner dashboard tokens and App Kit tokens are refused; default-store and workspace lessons retain their existing gates.
GET /api/lessons returns one bounded window and says how much it left out. The query takes limit (default LESSON_LIST_LIMIT = 50, clamped to [1, LESSON_LIST_LIMIT_MAX] = 500) and offset (rows skipped from the NEWEST end, clamped to [0, LESSON_LIST_OFFSET_MAX] = 100,000,000 because the vector tier binds it as a SQLite parameter and a value past the 64-bit range raises there instead of yielding an empty page); a non-integer is refused with HTTP 400 invalid_pagination rather than defaulted, because a page the caller cannot tell apart from the one it asked for is a wrong page. All three constants live in validation.py beside LEARN_LIST_SCHEMA, so the tool's advertised bound and the route's enforced one are one value. The body is {"lessons": [...], "total": N, "truncated": bool, "limit": L, "offset": O}: total is every live lesson in the store the caller is bound to (count_lessons() on the vector tier, the length of the global-plus-workspace union on the JSONL tier), truncated is len(lessons) < total — the body does not carry the population — and limit/offset are the values actually served, which is what makes the clamp non-silent. Rows inside the window stay oldest-first; offset walks back in time on both tiers (get_lessons(limit, offset) is ORDER BY updated_at DESC LIMIT ? OFFSET ? on the vector tier; the JSONL tier takes the window that ends offset rows before the tail of its append-ordered union). For V1 only, the vector-to-JSONL fallback is keyed on the vector POPULATION, not on the page, and the population test is whether any vector row DECODES -- a rendered row on the page settles it, otherwise has_any_decodable_lesson() scans the store -- not count_lessons(); the predicate is deliberately looser than context injection's has_any_lesson() because this list keeps every decodable row (legacy strings, rule-less mappings, marked withheld) so it stays deletable: an empty page past the end of a populated vector store answers [] with the vector total and never lists the superseded JSONL rows, while a store holding only rows an import or legacy migration left undecodable does not silence the JSONL file the caller's valid corrections still live in. count_lessons() sizes total only. A V2 member always answers from its sole SQLite database, including its total, even when empty or containing only undecodable rows; it never falls back to JSONL. A temporary session (blocks_reads) still answers the same shape with total: 0. The counts exist because this was the only lesson surface that omitted rows with no reader able to notice — injection reports Showing n of total, the memory graph reads the population, kirocrew learn list is unbounded — while learn_list rendered this body verbatim under a description promising "all", and learn_add's deduped outcome sends the model to that list to find the stored lesson it lost to, which as an older dedup winner (a dedup never bumps the winner's updated_at) sits exactly outside the newest window. learn_list therefore accepts limit/offset (validated by LEARN_LIST_SCHEMA against the route's own bounds), prints Showing N of M lessons; K older not shown -- pass offset=X to list them. as its first line whenever the body is not the population (K is total - offset - limit and X is offset + limit -- the window the store consumed, not the rows shown: the route drops a row whose stored JSON does not decode, so a short page still cost limit rows, and counting by the shorter number would overstate the rest and re-read that tail; the offset hint is dropped on the last page, and an empty page past the end reads Showing 0 of M lessons. rather than "No lessons saved."), and its description no longer says "all". A page whose rendered rows would pass MAX_RESPONSE_LEN (where sanitize_response cuts the tail while the header at the head survives) is refused whole with a limit that fits, never shipped as a "Showing N" claim over rows the model cannot see. The dashboard Memory tab reads the default window and shows the tail 20 rows with no count derived from the list, so it is unchanged; its lesson count comes from the status snapshot's count_lessons().
learn_add Session Authorization
The learn_add MCP tool (backed by POST /api/lessons) is subject to session-scope checks in dashboard/handlers/cron.py:api_lessons_create:
-
X-Session-Keyheader is required; missing → HTTP 400missing X-Session-Key. -
dashboard:ui(browser UI's static key) is always allowed. -
Otherwise the slot name (portion after the
:prefix, or the whole key) must satisfy at least one of:- Present in
state._slots(live in-memory slot), OR - Key is in
state._restricted_keys, OR - A dedicated subagent (
subagent:) or headless workflow (wf:,wf-pool:,wf-unpooled:,wf-worker:,wf-author:) has its full key registered in the realSessionManager. The prefix alone grants nothing. Shared subagents instead require an activeSubagentManagerrecord whose exact handle queue is still registered on its live ACP runtime. No prefix, saved run, parent PID or retained transcript substitutes for that live ownership. Teardown ends this recognition; retry/continuation must have a live allocation again. A live child also checks its admitted memory mode, captured before queueing or approval and stored in its canonical execution context. Active subagents use their gateway-owned snapshot; recovery reads the owning run record off-loop. Workflow workers use their captured execution context. The gate does not follow an editable parent field or consult a replacement parent slot. Unknown or damaged mode authority refuses withrestricted_session; incognito blocks writes and temporary also blocks recall. These checks only restrict an independently recognized child; a retained record never grants liveness. Dashboard/archive callers retain their persisted-mode checks even when their provider remains registered. This existence check does not replace restricted-mode checks, ordinary transport/owner/app authorization or canonical store routing, OR - Key is in a messaging-channel namespace — recognised by
messaging.link.is_channel_session_key()(any ofslack:/discord:/telegram:/webex:/wecom:/… perCHANNEL_SESSION_NAMESPACES), or it is a bare Slackthread_tsmatchingvalidation.SLACK_THREAD_TS_RE(^\d{10,}\.\d{6,}$), OR - The corresponding JSONL file exists under
~/.kiro/crew/sessions/{slot_name}.jsonl,~/.kiro/crew/sessions/dashboard_{slot_name}.jsonl,~/.kiro/crew/sessions/cron_{slot_name}.jsonl, or~/.kiro/crew/sessions/dashboard_cron-{slot_name}.jsonl— resolved by_session_has_persisted_history()inhandlers/_shared.py. The twocronforms exist becausehistory._safe_keyfolds:to_: a cron session keyed offcron:{id}persists ascron_{id}.jsonl, and its linked dashboard slot keyed offdashboard:cron-{id}persists asdashboard_cron-{id}.jsonl, so an idle-evicted cron session'slearn_addis recognised rather than rejected as forged.
If none match → HTTP 400
unknown session. - Present in
For subagent:<run-id>, the gateway requires the full key's live allocation,
including the original conversation key used by a continuation. A colliding
dashboard slot, restriction marker or retained transcript cannot establish that
session. Its captured memory mode must permit the operation. Session recognition
and ordinary transport/owner/app authorization remain independent of memory
routing through the canonical execution context; there is no private-memory
process proof or separate grant registry.
A Slack thread keys its session off the bare thread_ts (e.g. 1781215864.487849), set in slack/handler.py and frozen into the MCP subprocess's KIROCREW_SESSION_KEY env var; the slack:<chan>:<ts> form is only a send_message delivery target, never the session key. Recognising the bare-thread_ts shape is required because the session JSONL is written after the LLM turn completes, so the first learn_add in a fresh Slack thread would otherwise race the flush and fail with unknown session until the transcript lands on disk (then succeed minutes later). Dashboard keys are always prefixed (dashboard:*, chat-N-*), never a bare digits.digits, so the regex cannot widen authorization for dashboard or forged keys.
The same first-turn flush race applies to every messaging channel, not just Slack: a Telegram/Discord/Webex/WeCom session key is namespaced {channel}:{conversation_id} (e.g. telegram:kirocrew:forum:-100…:18:gen3) and, post-#232, the transport publishes session_pid so the gateway resolves it into X-Session-Key. Recognising the whole channel-namespace family via is_channel_session_key (not just slack:) is therefore the load-bearing acceptance for channel sessions. The _session_has_persisted_history fallback alone cannot rescue them: slot_name = sk.split(":", 1)[-1] keeps the inner colons (kirocrew:forum:-100…:18:gen3) and drops the channel prefix, while the on-disk file is dashboard_<safe_key>.jsonl with : folded to _ — so no probed name ever matches. Before this generalization, only slack: was accepted, so learn_add (and the other POST /api/lessons writers) failed with unknown session from every non-Slack channel even though the session was fully identified (regression #1268). The dashboard:/cron:/hook:/subagent:/channel: namespaces are deliberately not in CHANNEL_SESSION_NAMESPACES, so they resolve through live ownership, slot or persisted-JSONL paths rather than namespace acceptance.
The JSONL-existence check exists because MCP subprocesses retain their original KIROCREW_SESSION_KEY env var for the life of the process, but the gateway's idle-sweep loop evicts in-memory slots after ~60 minutes of inactivity (see session.py). Without this fallback, reopened dashboard tabs would deterministically fail learn_add once the slot is swept, even though the user is actively engaged.
Note that JSONL presence is not a negative signal for ephemeral sessions: incognito and temporary dashboard slots do write a session JSONL (_save_slot_to_history has no memory_mode gate — see history.md), because tab recovery and gateway-restart restore need the transcript. What "ephemeral" governs is memory — consolidation and lessons (is_restricted) and memory-context injection (blocks_reads) — not the session file. So the JSONL check widens learn_add session-scope acceptance to ephemeral slots too; the write itself is still refused downstream by the separate write-scope check. That refusal cannot rely on in-memory state alone: archiving a tab removes the slot from state._slots and discards its state._restricted_keys entry, so for an archived ephemeral session both in-memory signals are gone while the transcript remains. api_lessons_create therefore resolves existence and mode in one off-loop probe, _probe_persisted_session() (via asyncio.to_thread, so the blocking read never runs on the event loop — AUTOSDE no-blocking-call-on-event-loop), reading memory_mode back out of the metadata line of the very file the session-scope check accepted. One path resolution feeds both answers, so the two decisions can never describe different files. The mode reader returns a third state, None = unknown (no file, or no parseable metadata object as the first line) and the handler denies on it: a metadata line is written at file creation before any message is appended, so an unreadable header is not evidence that writes are permitted. A valid header merely lacking memory_mode reads as persistent, which is what a legacy pre-field session is. _is_restricted_session itself stays free of disk I/O — it has ~49 call sites and is a sync helper reached from async handlers — but for slack: keys it does call _hydrate_conv_flags() first (an in-memory SessionMap lookup), because _thread_incognito/_thread_temporary are process-local and populated only by inbound Slack messages: a cron, webhook, monitor or subagent turn would otherwise reach the gate with empty maps after a restart despite a durable !incognito on disk.
The learn_add tool wrapper in mcp_core.py maps the backend unknown session error into a user-actionable message that accurately reflects the post-check semantics — the error is returned only when the key matches none of the accepted forms above (live slot, restricted key, Slack namespace, or persisted JSONL), so the message tells the LLM to re-state the lesson in a new thread rather than promising an automatic retry that would cross a fresh LLM context boundary. This mapping depends on the transport helpers (_post/_get/_delete) decoding the structured {"error": ...} JSON body out of urllib.error.HTTPError (via _http_error_body()) rather than surfacing the opaque "HTTP Error 400: Bad Request" string — without that, the unknown session match never fires and the LLM sees a generic transport error. Because an HTTP response body is content originating outside KiroCrew, _http_error_body() redacts the decoded message with redact_exfiltration_urls() + redact_credentials() at that trust boundary before returning it, so every tool branch that echoes a transport error value inherits the redaction (per the security-controls rule: never trust output from outside KiroCrew on an external surface).
Every learn_add session-scope permission decision — both allows and the deny — emits a SEL audit event via _sel().log_api_access() with a distinct resources tag, so the full authorization flow is traceable in the audit log:
| Condition | Outcome | resources tag |
|---|---|---|
sk == "dashboard:ui" | allowed | dashboard_ui |
slot_name in state._slots | allowed | live_slot |
sk in state._restricted_keys | allowed | restricted_key |
Headless full key in SessionManager or an active shared child's exact live runtime handle | allowed | live_session |
is_channel_session_key(sk) (slack/telegram/discord/webex/wecom/…) or SLACK_THREAD_TS_RE match (bare thread_ts) | allowed | channel_namespace |
JSONL exists under ~/.kiro/crew/sessions/ | allowed | jsonl_fallback_recovery |
| None of the above | denied | unknown_session |
The downstream _is_restricted_session check can still reject the call with HTTP 403 after the session-scope allow decision (e.g. for an incognito/temporary slot), emitting a separate restricted_session_block deny event; that is a distinct write-scope authorization, not a session-scope one.
Cron Service (cron.py)
The dashboard list route returns {"jobs": [...]}. Agent Worlds polls that
envelope every five seconds and marks a cron sprite active only while is_running
is true; last_status remains the outcome of the latest completed run. A job with
is_running: true remains visible even when it is paused (enabled: false).
App-owned pause/resume uses CronSDK.set_enabled or its async sibling. The
existing service transition checks expected_owner after reloading the store
inside the mutation lock, so a stale SDK snapshot cannot authorize a foreign job.
A missing or foreign target raises and the SDK records an ownership denial.
The transition preserves ID/history and the existing user/auto-pause semantics;
a synchronous SDK call on the event loop refuses. App update methods reject
enabled and direct callers to the toggle API. No new cron grant is introduced.
Agent jobs capture an execution_context in the atomic schedule write. The
record distinguishes stable member identity from execution template and retains
the selected store independently of later parent-chat or configuration labels.
An explicit member uses its existing store; an omitted member inherits the
creating conversation. Unknown member identity is an error, not a Global fallback.
Dashboard creation reports invalid schedules with code: "invalid_cron"; a failed
write saves no job and sends no success refresh. Store-busy and unreadable-store
errors retain their separate recovery guidance. At execution, the job's captured
context reaches the runtime and every worker before provider startup. Continuation
keeps that member. Ordinary jobs without a member retain their existing V1 behavior.
Member-scoped execution does not require a protected filesystem or memory grant. Legacy V1 cron dispatch snapshots its selectors before resolving configuration off-loop; supplied canonical execution records require no configuration reread. Normal command/script permissions, application ownership and scheduler rules remain independent. Context assembly uses the captured member, template and project even when the memory database is unavailable; memory operations report the database failure. Minimal context keeps required member/project guidance while omitting optional context. Temporary execution reads no learning memory, and restricted execution cannot persist learning from its work.
Scheduled job execution with three schedule types: every (interval, min 60s), at (one-shot timestamp), cron (5-field expression). Supports natural language parsing via parse_wakeup().
Interval descriptions preserve the exact duration, using whole hours, whole minutes, or seconds (5400s displays as every 90m).
The editor and week grid prefer every_secs; their fallback schedule parsers accept s, m, and h suffixes when the numeric interval is unavailable.
The Schedule table defaults to Next Run ascending, ordering upcoming jobs by timestamp before jobs without a next run.
The cron editor sends empty channel and approval overrides on edit to clear stored values; creation omits unset overrides.
-
Persistence:
~/.kiro/crew/crons.jsonwith atomic writes and cross-process file locking -
Lock contract: mutators (
add_job/update_job/remove_job/enable_job) acquire the store lock via a bounded non-blocking spin (_file_lock); the loop-resident scheduling paths (timer tick,run_job) never take the store lock or_sync()on the event loop — they offload the locked_sync()+drain+snapshot to a worker thread viaasyncio.to_thread(_tick_scan_lockedfor_on_timer,_synced_snapshotforrun_job) and run only the mutation-free due-scan /_executingclaim on the loop; the reaper sweep snapshots the atomically-swapped in-memory job list cache-only (no lock, no_sync). The hot read paths (list_jobs/get_job) run on the event loop from many callers (per-connection status push, Slack, apps SDK, MCP) and are cache-only: they perform no filesystem I/O at all — no lock-file open, noread_bytes(), no digest hash, no_sync/_load— and simply returnlist(self._jobs)(never torn; CPython swaps the list reference atomically). This closes theno-blocking-call-on-event-loophazard: a largecrons.jsoncan no longer freeze the loop with a synchronous read+hash on every status push or every timer tick. Cross-process freshness for these reads is maintained off-loop: the in-memory snapshot is_sync()-refreshed under the store lock by the timer tick (_on_timer, every ≤_TIMER_POLL_SECS, in its worker-thread transaction) and by every mutator, so an external write is reflected within one poll interval. A freshly-constructedCronServiceperforms one synchronous_load()at construction so loop-less callers (MCP/CLI/apps-SDK processes, tests) read on-disk state immediately. That inline load is a whole-fileread_bytes()+blake2b hash, so an event-loop context must NOT use the plain constructor: the gateway builds its service inside its running async startup coroutine, where a synchronous construction-time load would block the sole loop (chat/WS/timers/heartbeat). Loop contexts therefore construct via the async factoryawait CronService.create(...), which passes_defer_initial_load=True(the constructor does no store I/O) and runs the initial_load()in a worker thread viaasyncio.to_thread;start()likewise offloads its_load()to a worker thread._runningisFalseduring both, so neither arms a timer off-loop. Callers that need a guaranteed cross-process-fresh read on the loop use the async variantslist_jobs_async()/get_job_async(), which offload the locked_sync()+read/hash/parse+snapshot to a worker thread viaasyncio.to_thread(degrading to the in-memory snapshot under lock contention rather than raising, and draining any deferred timer arm on the loop after the offload). The user-facingGET /api/cronshandler useslist_jobs_async(). Exhaustive on-loop-_syncaudit (enumerated in the_syncdocstring and enforced byTestReadPathsLocked+TestConstructionLoadOffLoop): every_sync()caller, rawread_bytes()site, and initialization/start()load is either in a worker thread (asyncio.to_thread) or a loop-less CLI/MCP/apps-SDK process — none runs on the gateway event loop. Write-path (_save) invariant: every_save()call site and every structuralself._jobsmutation holds_file_lockand is reached from the loop ONLY viaasyncio.to_thread— no bare on-loop_save()remains. This includes the reaper timeout (_force_reap) and user-cancel (cancel) terminal paths, which previously mutated the in-memory job and called an unlocked_save()directly on the loop: a lost-update race that re-serialized a stale job list and could silently drop a job a concurrentadd_job_async/update_job_asyncworker had just persisted. Both now offload the locked helper_merge_terminal_state_locked(mirroring_merge_job_result), which_sync()s first so the concurrent write is reloaded and the terminal fields are applied to the disk copy inside one lock transaction. The full writer→lock→loop-entry table lives in the_savedocstring and is enforced byTestTerminalStateMergeLocked(lost-update + off-loop-ticking regressions). -
Loop-safety guard (machine-enforced): the "no store lock on the event loop" invariant above is no longer only docstring-enforced.
_file_lockcalls_guard_off_event_loop()on entry: if a running asyncio loop is detected on the current thread it raisesCronLoopSafetyErrorunder strict mode (KIROCREW_STRICT_LOOP_SAFETY=1) or emits one throttled warning otherwise. This catches a future writer that calls a synchronous mutator on the loop (which would re-freeze it under contention) rather than letting it compile, pass tests, and regress silently. It is gated (default = warn) so an unforeseen legitimate on-loop caller is never hard-broken in production and the many existing tests that seed jobs via the sync mutators from anasync defbody keep passing; the regression suite (test_cron_arbiter_items.py::TestLoopSafetyGuard, run in CI) flips strict on to prove the guard fires and that the sanctioned async / offloaded-sync paths do not trip it. Operators can export the env var to escalate the warning to a hard failure fleet-wide (mirrorsKIROCREW_STRICT_ON_LOOP_PERSIST). -
App SDK (
CronSDK) sync/async contract: the app-facing mutation API exposed viactx.cron—add_job/remove_job/update_job/remove_all— is synchronous, preserving the published App Kit contract (a prior revision flipped it toasync defwith no shim, which silently turned a third-party app'sctx.cron.add_job(...)into an un-awaited coroutine that never ran). Each has a separately-named*_asyncsibling for loop-native callers. The sync methods never run on the loop:_run_sync_mutatorruns the blockingCronServicemutator inline only in a genuinely loop-less context, and when a loop IS running (an app calling the sync SDK from an on-loopon_startup/route hook) it refuses, raisingCronSyncOnLoopErrornaming the*_asyncsibling. Offloading the mutator to a worker thread was implemented first and rejected: it moves the lock acquisition off the loop thread (satisfying the guard) but the calling frame must still block on the worker's result, so the loop stays parked for the whole bounded lock window and every other gateway task — chat, WS pushes, timers, heartbeat — stalls with it. Relocating the lock does not unpark the loop. Running inline is not available either (the_file_lockguard rejects a store-lock acquisition on a thread with a live loop), so no correct synchronous on-loop behavior exists and the SDK fails fast at its boundary, before any mutation, rather than trading one app's convenience for an instance-wide stall. This is a breaking change for on-loop sync callers (CHANGELOG documents the one-lineawait *_asyncmigration); no in-tree caller is affected. In-tree loop-resident callers (theregister_app_crons_with_service/deregister_app_crons_from_servicebridges andon_app_disable) use the*_asyncvariants.remove_all/remove_all_asyncremove every owned job in ONE atomicCronService.remove_jobs_by_owner/remove_jobs_by_owner_synctransaction that SELECTS the owned set inside the lock — after the in-lock_sync()reload, against the authoritative on-disk store, not from a cache-onlylist_jobs()id snapshot taken before the lock. This closes a cross-process orphan window: because this PR madelist_jobs()cache-only, an owned-id set computed before the lock would miss a job the app created in another process since the last cache refresh; the locked removal would then delete only the stale ids, report success, and uninstall would proceed to delete the app — leaving an ENABLED orphan cron with no owning app. Selecting inside the lock removes it too (all-or-nothing — a contended store never orphans a still-ENABLED subset) and propagatesCronStoreBusyso a failed disable/uninstall cleanup is reported, not masked as a successful0. Enforced bytest_cron_arbiter_items.py(including a regression proving a job present on disk but absent from the cache is still removed). Cleanup-failure policy is deliberately asymmetric between the two lifecycle paths that call it.POST /api/apps/{name}/uninstalltreats cleanup as a hard precondition:_deregister_crons_with_retryretries the atomic removal_CRON_CLEANUP_ATTEMPTStimes with a short backoff (transient contention must not fail a user's uninstall; each attempt is all-or-nothing so a retry can never partially remove), and if the store is still busy the handler aborts with a retryable409beforederegister_app()— the app stays installed and its jobs stay consistent. It cannot proceed-and-log, because uninstall is irreversible:deregister_app()drops the per-app cron manifest and Step 4 deletes the app directory, so still-ENABLED owned jobs would become permanent orphans that keep executing their command/script/agent payload with nothing left that knows they belonged to a removed app. "Durably disable the jobs instead" is not an available fallback — disabling is itself a store mutation needing the very lock that is contended.on_app_disable, by contrast, reports and continues (result["cron_cleanup"] = "failed: cron store busy — jobs may still be enabled"): disable is reversible, the app and its cron manifest survive, so the cleanup remains retryable and hard-failing a disable would be worse than surfacing it. Enforced bytest_app_routes.py::test_uninstall_aborts_409_when_cron_cleanup_busy(abort + app still installed + retry count) and::test_uninstall_retries_then_succeeds_on_transient_cron_busy. -
Mutator failure contract (
CronStoreBusy): the bounded spin raisesCronStoreBusy(aTimeoutErrorsubclass) when the store lock stays contended past_FILE_LOCK_TIMEOUT_SECS. This is a deliberate public contract at every mutator boundary, translated per surface: the dashboard REST handlers return 409{"error": "cron store busy, retry"}; the MCP tools (cron_add/cron_update/cron_remove/…) return"Error: cron store busy, please retry"; the Slack command handlers reply with a busy message. All of these hand the retry decision back to a real caller (a human clicking, or an MCP/CLI client), so a contended store is a retryable error, never a dropped mutation. Best-effort bookkeeping (ack_job/unack_jobcontext trims) is the sole exception — it logs and continues, because the visible action already succeeded and the trim is cosmetic. The one fire-and-forget removal — the gateway removing a completeddelete_after_run/Done one-shot after delivery — has no caller to retry, so it does NOT drop:_deliver_script_resultcallsCronService.defer_removal(job_id), which disables the job in memory immediately (so it cannot re-fire) and queues the id in_pending_removals; the next timer tick drains it via_drain_pending_removals_locked()inside its worker-thread transaction (_tick_scan_locked, holding the store lock, before the due-scan), guaranteeing the finished one-shot is always eventually removed and never re-executed or re-delivered. An ordinary filesystem save failure requeues the removal and lets the tick scan the remaining in-memory jobs; the next tick reloads and retries, so one broken deferred delete cannot stop unrelated schedules. -
Mtime-based sync: auto-reloads when file modified externally (by the CLI or an out-of-band hand edit — agent-mediated writes are refused by the sensitive-path floor, see security.md; the store is a keystone leaf,
crons.json, since #4812) -
Load resilience (per-entry isolation):
_load()deserializes eachjobs[]record independently via the module-level_job_from_recordbuilder — a malformed or legacy record (missing required key, or not shaped like a job object) is warned about and skipped, and every well-formed job survives. A skipped record is NOT left in place: the next_save()rewritesjobs[]from the in-memory registry, so the bad entry is dropped from disk on the first write that follows (the load warning says so — the WARNING is the operator's only window to recover the record). The whole-store reset (empty registry + fingerprint reset) is reserved for a genuinely unparseable file (json.JSONDecodeError), where there is nothing to salvage.count_enabled_from_diskapplies the SAME_job_from_recordskip decision before counting, so the two readers of the store cannot drift (a record the scheduler refuses to load is never counted, and a non-dict entry cannot crash the WS status pusher). Before this, one bad record aborted the whole deserialization comprehension and theKeyErrorhandler silently replaced the entire registry with an empty list — and because_load()also runs from_sync()on every external file change, a hand-edit or partial schema migration could wipe the live schedule at runtime, not just at boot (#4664). Field-level type resilience rides the same builder, split by what a wrong type would cost (#9852): required identity/payload fields (id,name,message,schedule.kind), the schedule sub-fields (every_secs/at_tsrepresentable-finite-number-or-absent via the shared_is_representable_numberpredicate —boolrejected, NaN/Infinity rejected (they break comparison ordering and emit invalid JSON tokens), a bignum int rejected viatry/except OverflowError(int-float arithmetic on the timer-arming path would otherwise abort gateway startup);_build_joband_update_job_lockedmirror the predicate at the persistence chokepoints so the reader-side skip provably drops no writer-producible record; deliberately no FINITE value bound — extreme representable values load intact,format_schedule/CLI degrade unrenderable ones, andcompute_next_run_tsreturnsNonefor a non-finite arithmetic result),cron_exprstring-or-absent, the execution selectors (script,command,agent_id) and memory-identity selectors (member_id,memory_store—resolve_cron_memoryraises on a malformed binding, so coercing one to""would silently strip a member binding) and theagent_sequence/skip_dateslists raiseTypeErroron a mistyped value, so the record is skipped whole — coercing a selector would fail OPEN, silently flipping a script job into an LLM agent job via_cron_callback's mode fallthrough. Every other string-typed field read with.get()coerces to its own unset value ("", orNoneforOptional[str]fields whereNonemeans "never set" and must not become""), every numeric telemetry field (last_run_ts,created_ts,last_result_ts,last_posted_at,last_failure_at,secret_env_pending_ts,consecutive_*,timeout_secs,timeout) coerces a non-representable stored value to its declared default — coercion drops no record, and it is what keeps a stored NaN out of the secret-grant CAS (NaN != NaNwould permanently block approval) and out of timer/due/delivery arithmetic — and each field whose stored value was destroyed is named in one WARNING emitted by_loadalone (_is_loadable_recordprobes withwarn_on_coercion=False, since it runs at WS status-pusher cadence and never precedes a store rewrite) — the operator's recovery window, since the next_save()replaces the stored value — so a hand-edited record storing a number or null where a string belongs can never flow into theGET /api/cronsredaction pipeline, which calls string methods and would 500 the whole listing on one bad record. Enforced bytest_cron_load_malformed_entry.py. -
Timer restore:
_load()re-arms the timer loop for active (enabled) jobs when the service is already running, ensuring jobs resume after gateway restart -
Timer cap:
_arm_timer()caps delay at_TIMER_POLL_SECS(30s) so the timer always wakes to_sync()and detect external file changes — fixes one-shot reminders set via Slack silently failing -
Timer loop: non-blocking execution via independent
asyncio.create_task()per job (via_run_job_isolated()), with strong refs in_running_tasksto prevent GC. One hung ACP session no longer freezes the entire cron system. Per-job timeout increased from 5 min to 30 min (_JOB_TIMEOUT_SECS); zombie detection (is_responsive()after 10 min inactivity) provides additional defense-in-depth -
Reaper clock: the
_reaper_loopbackstop decides on the monotonic clock, not the wall clock — it comparestime.monotonic() - _job_start_monotonic[job_id]against the deadline, where_job_start_monotonicis stamped in_run_job_isolatedand popped in lockstep with_job_start_times(itsfinally, the reaper's done-task cleanup,_force_reap,cancel). The primary guard it backs up is_execute_with_timeout'sasyncio.wait_for, whose deadline also runs on the loop's monotonic clock, so measuring on a different clock makes the two disagree whenever the wall clock jumps: a job spanning a host suspend looks like it ran for the whole sleep window and is force-killed with a spuriousstatus=timeoutin history, whilewait_for(correctly) never fired. An entry with no monotonic stamp (a run already in flight across an upgrade) falls back to the wall-clock elapsed, so the backstop never stops reaping._job_start_timesstays the wall-clock map and remains what human-facing surfaces read —running_since(), history timestamps, and the reaper's own "ran Ns" log line. Consequence to expect: a suspend-spanning job is no longer killed on wake and survives until the monotonicwait_fordeadline, then records a genuine timeout. -
Semaphore safety:
_acquiredflag pattern in gateway.py, handler.py, chat.py, task_executor.py prevents over-release whenget_or_create()throws -
ACP zombie detection:
_last_activitytimestamp on AcpClient,is_responsive()returns False after 10 minutes of inactivity -
Job execution: resets LLM session, streams response, posts to the job's own surface plus the dashboard (unless
silent). Delivery is routed by ORIGIN:_deliver_cron_to_channelresolves the session that CREATED the job (_cron_origin_key, offjob.session_key) and, when that session belongs to a non-Slack channel, delivers there and the Slack owner-DM leg stands down, so one run notifies one operator once. Every leg gates on that send's own return value rather than on a prediction, so a governance refusal or a wire failure falls through to Slack instead of dropping the run, and the SELdownstream_servicenamesslackonly when the Slack post landed. An explicitjob.channelis a destination the user pinned and takes precedence over both. A Slack-origin, dashboard-origin or origin-less job keeps the Slack leg, which is every job an install carries today. The same routing covers the result, the run-failure alert and the crash alert. Delivery via_deliver_cron_response: attempts configuredchannel/thread_tsfirst, falls back to owner-DM if channel delivery fails; applies boundary redaction to all output before posting. It also renders any[OPTIONS: ...]tags as interactive Slack buttons —extract_options()strips them from the text andbuild_options_blocks()posts them under a try/except guard so a Block Kit failure never blocks the text delivery (gateway.py) -
Session scoping: identity comes from the per-call caller block gatewayd injects (
_meta.kirocrew.caller, consumed via_resolve_session_key_strict), withKIROCREW_SESSION_KEYandKIROCREW_HOST_PID+HMAC as the non-gateway-launch fallbacks; the lenient/procpid walk is deliberately NOT an authorization source.kirocrew-cronadvertiseskirocrew.caller-identityso gatewayd injects that block — without the advertisement it is pooled AND identity-blind (nothing declines to pool an unadvertised backend), which is why every session-scoped path used to read an empty key and fail open. One scope function,_owned_by, gates BOTH reads and writes: a caller reaches only the jobs whosesession_keymatches its own. Consequences: an unidentifiable caller (gatewayd forwardscaller=Nonewhen a stub registers with no key and peer resolution fails, so it may be sharing a pooled backend with identified sessions) reaches nothing and every write refuses;cron_addrefuses rather than storing an ownerless row; a job with no recorded owner — written bykirocrew cron add, the onboarding importer, and pre-fix pooledcron_add— is outside every session's scope in both directions, so it does not appear incron_listfrom chat and the CLI remains its management surface;cron_remove_allremoves only the calling session's jobs. There is no admin bypass on this server and no ambient environment value grants cross-session scope: identity comes only from the sources_authz_session_keyaccepts. The CLI's authority does not come from this server at all —kirocrew cron ...reachesCronServicedirectly and never routes through it, which is why the refusal text names that route.cron_addalso takes its default delivery channel from the caller block'schannelId, sinceKIROCREW_CHANNEL_IDhas the same one-session-per-process defect as the key -
Ownership release on permanent session deletion: A scheduled job outlives the conversation that created it, so permanent history deletion releases ownership instead of deleting the job. The job keeps its schedule and remains enabled with
session_key = ""; tab close does not reach this funnel. Before the first await,api_session_deletefreezes #10019's immutable_HistoryDeleteClaim(slot object, transcript route, task identity and manager generation) and performs #9109's strict cron-store owner scan._delete_history_sessionthen resolves any ambiguous canonical/legacy transcript route and reads a validlinked_session_keyinside onelocked_stems(transcript_lock_stems(key))hold, before unlink. It binds only exact owner keys established by that store scan, the lock-verified live-slot route, or the readable metadata field into the immutable claim. It never reconstructs an owner from a lossy filename fold. A known store failure refuses before unlink with409 cron_store_busyorcron_store_unreadable; unreadable transcript metadata returns409 cron_ownership_unknown, leaves the row intact, and names the required sequence: release candidate jobs by id, repair the transcript metadata, then retry. An outer history-lock timeout reports a failed delete rather than a 500.After unlink,
_owner_keys_after_unlinkrepeats the strict scan to catch a job created in the scan-to-unlink window._remove_slot_for_history_keyaccepts the completed claim, revalidates the slot, transcript, task and manager generation, cancels only that old turn, and callsdestroy_iffor only that generation. It preserves pins, work ledgers and autocompact overrides because no request-local scan can prove cross-process ownership of those independent sidecars. After the teardown awaits it rechecks live slots plus SessionManager's live and reserved keys, then passes only proven keys with no current runtime owner toCronService.release_jobs_owned_by; that store transaction reloads under its own lock, then snapshots the synchronized ordered set of distinct exact per-run keys before compare-and-clear; a run that persisted a child before lock acquisition is present in both snapshots, while a later run cannot persist until the lock is released. It keeps a still-live stablecron:<job id>principal, excludes every live stateless key before folding other spellings of that principal, removes a whole exact key after successful reset, and pop-reinserts on registration so reaper/cancel still target the newest run without forgetting older sessions retained for pending subagents. Release failures warn with candidate ids andkirocrew cron adopt <id> --release. The bulk delete uses the same contract with one pre-scan and one post-scan for the whole batch, carries one immutable claim per unlinked row, and reports unreadable rows asundeletable: [{id, code}]. The dashboard keeps refused rows and renders localized notices from the machine code. Tests live intest/test_remove_slot_for_history_key.py,test/test_dashboard_sessions_clear.py, andtest/test_dashboard_sessions_clearable_count.py; the frontend refusal contract is inwebsite/src/test/historyDeleteRefusal.test.tsandChatSliceCoverageSecondPass.test.tsx. -
Silent mode:
CronJob.silent = Truesuppresses auto-delivery of results; the agent decides when to notify the user via thesend_messageMCP tool. Silent also gates failure broadcasts: the failure-alert sites — the dup-failure dashboard bell, the fresh-failure dashboard bell, and the fresh-failure Slack DM (gateway.py) — are wrapped innot job.silent, so a silent cron's failure is recorded but not broadcast. The dedup-state advance (last_failure_hash/last_failure_at), the consecutive-failure auto-pause, the SELcron_failure_alertaudit, and the re-raise still fire regardless ofsilent -
Per-agent cron: jobs store optional
agent_id; gateway passesagent=job.agent_idtoget_or_create()so each job runs with its configured agent -
Handler intercepts cron commands before ACP (no LLM round-trip needed)
-
CLI:
kirocrew cron {list|add|update|remove|pause|resume|trigger}.addandupdateaccept--every,--cron,--channel,--agent,--approval-mode. CLI flags mirror the correspondingcron_add/cron_updateMCP tool parameters. -
Update:
CronService.update_job(job_id, **kwargs)for partial updates (name, message, schedule, agent, channel, thread_ts, approval_mode, silent) with file locking and cron expression validation.channelandthread_tsare the delivery pair and both clear on a falsy value (""/None→None, matching howmcp_cronnormalizes a blankthread_ts); a field the caps table validates but this method does not assign would be accepted and silently dropped, so every updatable string field needs its own branch here. Re-threading a job that holds an activesecret_envgrant deliberately fails its next run closed:thread_tsis bound intocron_script.delivery_fingerprint, which the runner recomputes and the grant pin must match, so the operator has to re-approve before secrets are injected again -
Message cap: the cron
messagehas its ownMAX_CRON_MESSAGE(50 000) cap — a cron message is a task prompt, so it does not borrow the sharedMAX_MEDIUM_STRING(5 000). Enforced on the MCPcron_add/cron_updateschemas,POST /api/cronsandPATCH /api/crons/{id}(PATCH routesmessagethroughvalidate_string_field, 400code: "invalid_message"on non-string/oversize — it was previously unvalidated on that surface), and at theCronServicepersistence chokepoint (_build_job/_update_job_lockedraiseValueError), so the CLI and apps-SDK paths cannot admit a value the validated surfaces would reject -
Chokepoint string-field gate:
_build_job/_update_job_lockedtype/length-validate every caller-supplied string field through one table (_CRON_STRING_FIELD_CAPS— name, message, channel, thread_ts, agent_id, created_by, folder_id, chat_folder_id, session_key, model, command, script, timezone), with caps matching the REST/MCP boundary schemas. Non-string truthy values and over-cap strings raiseValueErrorat the persistence owner regardless of caller;None/""keep their "not set"/no-op semantics while any other falsy non-string is rejected. An anti-drift test (test/test_cron_string_field_validation.py) asserts every persisted str field onCronJobis either in the table or in a documented runtime-only exclusion set -
Async stop:
stop()is now async, cancels and awaits_running_tasksbefore returning
Result delivery order, and the delivery-agnostic dedup anchor
A finished message cron fans out to three surfaces, in this order:
- the dashboard slot + bell —
inject_cron_result_to_dashboard(gated onpersistent_session and not hide_in_chat; the tab is filed into the job's chat folder when it is minted, see Run Filing into a Chat Folder) thennotify; - the originating channel, when
job.session_keynames a non-Slack conversation._deliver_channel_replyruns the governed cross-surface ladder (origin link → non-Slack mirror link → a direct session's stored peer id; see slack-gateway) and reports whether the send landed. Without this rung a cron created from Telegram reached only the bell and Slack, so a Telegram-only operator's scheduled jobs ran and reported nowhere they would ever see; - Slack, only when rung 2 did NOT deliver, so a channel-born cron never double-posts.
job.last_posted_hash / consecutive_dupes / last_posted_at are written by one
helper, _record_cron_delivery, from rungs 2 and 3 — never from rung 1. That
split is the contract:
- Any confirmed delivery advances the anchor. The anchor is what the
duplicate-suppression read compares against, so a surface that delivers
without advancing it can never suppress. While only the Slack branch wrote it,
a channel-delivered cron kept
last_posted_hash == ""forever: identical output re-posted on every tick, and the "same result N times in a row" reminder could never fire there at all. - Suppression is therefore delivery-agnostic too. An identical result now
suppresses the channel post as well as the Slack one, re-posting after
_SUCCESS_REMINDER_SECS(24h) with the "same result N times in a row" caption. For a channel-delivered cron this is a behaviour change from "always post" to "suppress", and the intended one — identical output is equally noisy in a chat. - The dashboard-notification-only path does NOT advance it. The bell is passive and the operator may never open it, so counting it as delivered would suppress a result nobody has seen.
The Slack branch additionally records where its post landed
(sessions.set_thread / set_channel on the cron:{id} key) so a later
subagent completion can be threaded onto it. The channel leg deliberately has no
equivalent: its conversation was resolved FROM the creating session's own durable
origin/mirror link rather than learned at send time, and _channel_reply_link
refuses a cron: key outright (no channel namespace) while its stored-value rung
expects a direct peer's user id rather than a conversation — so the write would
be inert. Routing a cron's subagent completions back to the creating channel
needs a cron:{id} → creating-key edge, which does not exist yet.
Failure alerts take the same ladder
A failure reports where the results report. Both failure surfaces run the channel-first ladder above and keep Slack as the fallback:
_alert_cron_failure— the script/command arms, which signal failure by mutating the job and returning normally, plus fire-time policy denials;- the
messagearm's ownexceptbranch, which alerts and then re-raises.
An alert reaching only the dashboard bell while the results reach a chat is worse than a uniform gap: the success path is what taught the operator to watch the channel, so a job crashing every minute there reads as an idle one — the exact state the alert exists to prevent.
job.silentgates every surface identically._alert_cron_failurereturns before any of them; themessagearm gates the channel leg onnot job.silentexactly as it gates the Slack DM. A silent job still counts toward auto-pause.- The channel gets an unescaped twin, not the Slack string. mrkdwn escaping and
fence-neutralization are Slack-sink concerns; a ``` fence or a
<would reach another channel's reader literally. Both legs compose from the same already-redacted pieces, and_deliver_channel_replyadds the egress redaction pass. - The failure dedup anchor is delivery-agnostic too. A channel-delivered alert
never sets
slack_failed, solast_failure_hash/last_failure_atadvance and an identical failure re-alerts once per_FAILURE_REMINDER_SECSrather than on every fire. The SELdownstream_servicenames the channel namespace that actually took it, so the audit trail does not read "slack" for a Telegram delivery.
Per-Job Timezone
Each job stores an optional timezone field (IANA name, e.g. America/Los_Angeles). Schedule evaluation, next-run computation, and display all use the job's timezone rather than the server timezone. The /api/crons response includes both the per-job timezone and a top-level server_tz field so frontends can render correctly.
The shared schedule formatter receives the saved job timezone at every display boundary, including cron_add and cron_update confirmations, cron_list, kirocrew cron list and kirocrew cron add, the Schedule page, and channel command listings. A job without its own timezone retains the published server-timezone fallback.
Skip Dates
Jobs can define skip_dates — a list of dates (YYYY-MM-DD) on which the job should not fire. The executor already skipped these at runtime; compute_next_run_ts now also advances past skip_dates when computing the display/preview "next run" time. The advance is bounded by a wall-clock horizon (~2 years, _MAX_SKIP_DATE_HORIZON_SECS) rather than a fixed iteration count, so the bound does not couple to schedule granularity — a daily and a */5 cron both simply look ~2 years ahead for the next non-skipped fire (an earlier fixed iteration cap sized for one granularity silently returned None — the job never firing again — for finer-grained schedules with long skip ranges). A large absolute iteration ceiling (_MAX_SKIP_DATE_LOOKAHEAD) remains only as an anti-infinite-loop safety net for a pathological all-skipped sub-minute config. The /api/crons response includes skip_dates per job.
Strict Schedule (strict_schedule)
Jobs receive random jitter by default (0-5min hourly, 0-59min daily) to spread load. Sub-hourly schedules receive no jitter: intervals shorter than one hour, and cron expressions whose parsed minute field fires more than once per hour. The cron parser expands and deduplicates wildcard, step, list, range, and combined forms; a single literal minute keeps the existing hourly, multi-hour, or daily band selected from the hour field. Set strict_schedule: true on a job to disable jitter entirely — the job fires at the exact cron/interval time.
Create-path persistence-owner validation (timezone / skip_dates)
CronService.add_job()/update_job() in cron.py are the persistence
owner for cron creation/mutation. They validate timezone and skip_dates
before the job's single _save(), and fold both fields into that first
persist — so no create path (MCP cron_add, apps cron_sdk, dashboard, CLI)
can strand a half-populated or invalid job on disk for these two
calendar-validity-sensitive fields:
- Timezone is checked with
cron.is_valid_timezone()— a single cachedZoneInfo(tz)constructor lookup, NOTavailable_timezones()(which recursively walks the tzdata tree and opens many files, blocking the async dashboard PATCH event loop). An invalid IANA name raisesValueErrorand nothing is persisted. - Skip dates are checked with
cron.is_valid_skip_date(), which requires the parsed value to round-trip exactly back to%Y-%m-%d. This rejects non-padded inputs like"2026-1-1"thatstrptimeaccepts but that render as"2026-01-01"at fire-time comparison and would therefore silently never match. An invalid entry raisesValueErrorand nothing is persisted.
Because validation precedes the single locked persist, a cron_add with an
invalid timezone/skip_dates returns an error and leaves no job on disk,
so a retried create cannot duplicate a previously-stranded job (the earlier bug:
validation ran after the immediate _save(), so the invalid job persisted and
a retry duplicated it). mcp_cron.cron_add keeps only a thin pre-check to
return a redacted user-facing error message and passes the values through to
add_job for the authoritative check.
Scope (field-partial invariant). This owner-level guarantee currently covers
timezone and skip_dates. The other first-save fields (agent_id, model,
session_key, strict_schedule, hide_in_chat, silent) are still applied by
the dashboard and MCP create handlers as a post-hoc fold + a second _save()
after add_job returns. Totalizing the invariant over all first-save fields
(so every create is a single fully-formed locked persist) is tracked in
issue #391 and delivered by the concurrent fix/cron-locking rework
(PR #331), which consolidates every first-save field into one
_build_job/_persist_add_locked transaction.
App-Manifest Cron enabled Flag (register-paused contract)
App manifests (app.json) may declare crons with "enabled": false — a cron
the app ships disabled-by-design (e.g. a nightly job that needs user
configuration before it is useful). The contract, end to end:
- Parse:
CronEntry.enabled(apps/manifest.py, defaulttrue). The value must be a JSON boolean — a non-boolean (e.g. the string"false", truthy under coercion) is rejected byAppManifest.validate()with a clear error; coercion remains only on the self-writtenapp-crons.jsonread path. Serialization is sparse —to_dict()emits the key only whenfalse. - Persist:
_register_crons()carriesenabledthrough the app'sapp-crons.json. A legacyapp-crons.jsonwithout the key registers active (defaulttrue— behavior unchanged for existing apps). - Register:
register_app_crons_with_service()(apps/bridges.py) threadsenabledtoCronSDK.add_job_async(enabled=...)(apps/cron_sdk.py), which forwards it toCronService.add_job_async(enabled=...). A disabled cron is created paused —enabled=False+user_paused=True, mirroringCronService.enable_job(False)— atomically in the job's first persist (never an enabled-then-paused two-save window that a crash or a concurrent reader ofcrons.jsoncould capture as enabled). It is visible in the Schedule view and resumable viacron_resume/dashboard, never silently active and never invisible. - Startup idempotency: gateway-startup re-registration skips existing
jobs by name using
list_jobs(include_disabled=True), so a paused job IS found and skipped — a user-resumed cron survives gateway restart un-clobbered. - Lifecycle caveat:
on_app_disableremoves the app's jobs; re-enabling the app re-registers from the manifest, so a cron the user had resumed returns to paused (manifest is the sole source of truth on re-register). Documented indocs/app-kit/manifest-reference.md.
Hide in Chat (hide_in_chat)
By default a persistent-session agent cron auto-creates a linked dashboard chat slot (cron-{job_id}) at the start of its first eligible run (ensure_cron_slot, so session-control caller identity and dashboard-surface routing work during the run itself — issue #8336; delivery's inject_cron_result_to_dashboard then finds the same slot idempotently). The pre-create is best-effort (_pre_create_cron_slot): a failure minting the tab is logged and the run proceeds without it (delivery's own bind still creates the tab afterwards), and a wake-deadline cancellation landing inside the pre-create leaves run_never_started armed so a delete_after_run one-shot is retained rather than consumed by a run that never dispatched — the tab is an amenity of the run, never a precondition. Runs appear in the active session list. Set hide_in_chat: true to suppress that slot creation — the run's result still reaches Slack/dashboard notifications, and the run stays visible in the History tab via the cron execution-history store (CronHistoryStore, written by the executor whenever the store is usable — see History is best-effort below — and surfaced at GET /api/crons/{id}/history), but no entry clutters the Chats sidebar. Useful for fire-and-forget jobs (daily digests, log cleanups, polling). Default false (preserves prior behavior; absent field reads as false). Orthogonal to silent: silent suppresses the push notification, hide_in_chat suppresses the chat slot. The flag is a no-op for script/command crons, which never create a slot. The executor gates all three inject_cron_result_to_dashboard call sites on not job.hide_in_chat; the dashboard notification's CTA falls into the pre-existing no-slot branch ("View last result", which lazily rebuilds a slot from history on click) instead of "Go to Chat". Note: the cron:{job_id} dashboard conversation_log is written ONLY by inject_cron_result_to_dashboard, so it is intentionally empty for a hidden cron — it exists solely to give a dashboard follow-up turn context, which a no-slot cron never has. Hidden-cron result persistence is the execution-history store, not cron:{job_id}.
Cron Folders (folder_id)
Cron jobs carry an optional folder_id field (string, default "") that groups them into user-defined folders in the dashboard UI. Folder definitions are persisted in ~/.kiro/crew/cron_folders.json as a JSON list of [{id, name, order}] objects, managed by DashboardState.load_cron_folders()/save_cron_folders() with the same atomic-write pattern as chat folders.
REST endpoints (registered at /api/cron-folders to avoid ambiguity with /api/crons/{job_id}):
GET /api/cron-folders— list all folders.POST /api/cron-folders— create a new folder ({name}→{id, name, order}).PATCH /api/cron-folders/{folder_id}— rename a folder ({name}).DELETE /api/cron-folders/{folder_id}— delete a folder and clearfolder_idon any assigned cron jobs (jobs are never deleted).
Job field wiring: folder_id follows the same pattern as hide_in_chat — accepted on POST /api/crons (create) and PATCH /api/crons/{id} (update), included in the GET /api/crons response, persisted in crons.json via _save()/_load(), and threaded through _build_job()/add_job()/add_job_async()/_update_job_locked().
Template provenance (source_preset, source_template_prompt): two create-only strings stamped on a job seeded from a Schedule-page template. source_preset is the clicked SchedulePreset.id (e.g. "error-digest"); source_template_prompt is the template's prompt text AS IT WAS at that moment (a snapshot, written once, never updated). Both are "" for a blank create or any non-dashboard create surface. Only the dashboard has the template gallery, so only POST /api/crons accepts them (validated through the same validate_string_field/_CRON_STRING_FIELD_CAPS table as the other string fields; the snapshot carries the message cap, not the ID cap); PATCH accepts neither, because provenance is fixed at creation. They are set on the freshly-built job inside add_job_async (the only create path a template ever reaches) BEFORE its single off-loop persist — deliberately NOT threaded through _build_job()/add_job()/CLI/MCP, which no template ever calls, so the shared constructor carries no unused parameter. Persisted in crons.json via _save()/_load() (_job_from_record defaults both to "" so every job saved before the fields existed still deserializes and simply never shows the hint), and returned in GET /api/crons through the SAME redact_exfiltration_urls+redact_credentials pipeline as every other free-text field on that payload — both are client-settable POST fields, so they cannot leak a credential-shaped value back to dashboard clients unredacted. They NEVER gate scheduling or execution.
Template-updated hint: on a saved job whose source_preset matches a preset still in the shipped SCHEDULE_PRESETS catalog AND whose SAVED source_template_prompt snapshot differs from that preset's CURRENT prompt, the job detail panel shows a low-key, dismissible notice (accent-subtle/text-muted, role="note", data-testid="schedule-template-updated-notice", pages.schedulePage.template_updated_notice) that NAMES the template. The comparison (templateUpdate in schedulePresets.tsx) is deliberately against the SNAPSHOT, not the job's live message: comparing the live message would be symmetric and could not tell a template that moved from a user who edited their own copy. It is also against a LOCALE-STABLE operand — both the snapshot (captured at create time via presetCanonicalPrompt) and the read-time comparison resolve the preset prompt through the canonical English source (i18nT(key, { lng: "en" })), not the viewer's-locale getter. Without that, the snapshot would freeze one locale's rendering and a language switch or a translation-only catalog edit would fire a false "template updated" — the same false-attribution class on a different axis. Only an edit to the English source (what a genuine template-prompt fix touches) moves the operand. The displayed prompt in the notice is still the viewer's-locale rendering; only the change DETECTION is canonical. There is no template-side revision integer or prompt hash — a text snapshot cannot drift. The notice copy uses the word "message" (matching the create form's Message field) and offers a Dismiss control (the shared Btn primitive, per the page-layout AUTOSDE rule — never a raw element) whose dismissal is persisted (localStorage, keyed on job id + a fingerprint of the compared prompt) so it silences THIS change but re-appears if the template moves AGAIN. Retired templates (id no longer in the catalog) show no hint. This is strictly additive: no migration, no change to the copy-at-save model.
Run Filing into a Chat Folder (chat_folder_id)
A SECOND, unrelated folder field (issue #1620). folder_id above groups the job's ROW on the Schedule page (cron_folders.json); chat_folder_id names a folder in the chat sidebar's own tree (folders.json) and decides where the job's cron-{id} TAB lands. A job may carry either, both or neither, and neither is ever derived from the other. Persisted on the job record, ""/absent = not filed, capped through _CRON_STRING_FIELD_CAPS, accepted by POST/PATCH /api/crons and returned by GET /api/crons.
What it is for: a recurring job's tab otherwise sits loose in the sidebar. With a chat folder set, the tab lives in that folder, and its run stamps and run markers already make it the job's timeline.
Persistent jobs only. A stateless job (persistent_session=False) has no job-wide tab to file, so the store refuses the pair at save time (_CHAT_FOLDER_NEEDS_PERSISTENT, a ValueError the REST handlers surface as 400) — on create, and on an update that itself names a folder for a job it leaves stateless — rather than accepting a setting with no observable effect. Turning persistence OFF on a filed job is not refused: the store clears chat_folder_id in the same update and fills the caller's transition sink with the prior folder exactly as an explicit clear does, so the dashboard handler moves the tab through the same save-time path. A refusal there would be a dead end, because MCP/CLI cron_update exposes persistent_session but not chat_folder_id, so that caller could never un-persist a filed job. The Schedule form disables the picker for such a job and says why (chat_folder_needs_persistent_session). A script/command cron never gets a tab either, so the form does not offer the picker for one and sends "". hide_in_chat SUSPENDS filing: the form disables the picker and says why while KEEPING the stored folder, since the runtime already ignores it for a hidden job and wiping it would make the form's own "turn off Hide in chat to use this" hint false. Per-run tabs for stateless jobs are deliberately out of scope; filing each run of a stateless job would be a follow-up.
It supplements delivery, never replaces it. Filing is a placement, not a fourth delivery mode: the notification, the Slack post and the origin-session injection all still happen exactly as before.
A tab is filed by the call that MINTS it, and by nothing later. _bind_cron_slot — the single creator for both the run-start pre-create (ensure_cron_slot) and the delivery — reads whether the slot existed BEFORE get_or_create_slot and schedules its filing (persist_cron_chat_folder) only when it did not. "Unfiled" is deliberately NOT the test: a reader who drags the tab out to the root leaves slot.folder_id == "", indistinguishable from never-filed, so a rule that filed unfiled tabs would drag theirs back on every delivery. A freshly minted tab has no placement history, so filing it cannot undo anyone's. The synchronous path only DECIDES (chat_folder_for_minted_tab); the assignment of slot.folder_id belongs to the commit helper on the background task, because the delivery publishes the slot table synchronously right after the mint and a provisional value there would be broadcast before any write.
Folder lifecycle. A RENAME is a no-op — the job stores the folder's id, so it follows the folder under its new name. A DELETE leaves a dangling id, and the contract matches folder_id's: a dangling id means "not filed". The run still delivers, the tab lands unfiled, and the skip is recorded ONCE (a log line plus a cron_chat_folder_missing SEL row with outcome skipped) so "why is this job not in my folder?" has an answer without a repro. Existence is also checked at SAVE time, where a person is present to be told: POST/PATCH /api/crons refuse an id naming no folder in state._folders with 400 unknown_chat_folder (non-string or over-cap: 400 invalid_chat_folder_id; null means "not filed").
Persistence of the placement. The folder's contents are read from the on-disk session list (list_sessions carries each session's folder_id), so an in-memory assignment alone would vanish on restart. Every placement — the filing of a minted tab and the save-time move of an existing one — commits through ONE helper, _commit_cron_tab_placement: it assigns slot.folder_id immediately before the write it feeds (save_slot_off_loop serializes the folder off the slot, with no override), asks for a confirmed verdict (force=True, best_effort=False; the returned bool decides, since a save can refuse without raising), pins the write with expected_history_key and expected_slot_name so a tab the reader closes and archives — or a same-name recreate — while the write is in flight refuses rather than overwriting the closed session's metadata, tells the sidebar about the placement only after the verdict, and rolls the tab back (to unfiled for a filing, to the prior folder for a move) when the write did not commit — unless the reader moved it meanwhile, in which case their placement stands. The filing runs on a tracked background task so no suspension point sits between a run's result and its notification.
Changing the field — clearing it, switching folders, or filing a job that was unfiled — moves the job's EXISTING tab at the SAVE (move_cron_job_tab, called by PATCH /api/crons/{id} after the store commits), under two conditions. First, a real TRANSITION: the store fills a caller-owned sink (chat_folder_transition_out, read under the same file lock as the write, so it is neither a read-then-write race nor shared across concurrent updates) with the PRIOR value — "" included — only when the update actually changed chat_folder_id; the Schedule form sends the field on every submit, so the request alone is evidence of nothing. Second, the prior folder is checked AGAINST THE TAB: it moves only when it sits where the setting last left it. A reader who dragged it elsewhere has said where they want it, and their placement outranks a setting they are editing.
Code-Based Script Execution (cron_script.py)
Deterministic cron jobs that bypass the LLM entirely:
Command preflight scans wildcard markers and local-assignment boundaries without
repeated suffix scans. Assignment recognition splits at whitespace and command
separators, partitions each word at its first =, and requires an ASCII identifier
on the left. Quoting and escaping retain
their conservative treatment in this recognition stage; expansion is separate.
Its conservative credential checks still refuse more than
64 local assignments and wildcard-bearing words longer than 256 characters.
Literal unmatched brackets remain ordinary text. This does not change which
command or script runners may access member memory.
- Script mode:
scriptfield specifies a Python callable path (~/.kiro/crew/crons/file.py:function). The function receives aScriptContextwithctx.call_tool()(MCP tool access),ctx.notify()(deliver message),ctx.message(arguments from themessagefield). Control flow via exceptions:raise Skip()to silently retry next tick,raise Done()to complete and remove the job,raise Report("msg")to deliver a message and keep running. - Script-mode MCP identity: a script cron runs as the principal
cron:<job id>— the same keyScriptContextpresents to the gateway over HTTP (X-Session-Key/caller_session) and the key an agent cron's session runs under, so ownership and audit see one principal per job whichever surface the job uses. It is delivered on two hops:run_script_sandboxedexportsKIROCREW_SESSION_KEY=cron:<job id>into the script child, andMcpToolClienthard-assigns the same key on the env of every MCP serverctx.call_tool()spawns — after both the inherited-env and per-serverenvoverlays, so a script that rewrites its ownos.environcannot present a different principal (best-effort friction on the same footing as the rest of the env-based identity contract, not a containment boundary). This is load-bearing rather than cosmetic: a direct MCP spawn is not routed through gatewayd, so it gets no caller block, and nobody publishes a signedsession_pid_*sidecar for the launcher pid — leaving a script cron with none of the three sources_resolve_session_key_strict()accepts, which fails closed for writes only. Every state-mutating tool then returns its refusal as an ordinary result string while read-only calls keep working, so the job reportsokand writes nothing. Two consequences to keep in mind:cloud.aws.assert_human_actionkeys on the same env var, so destructive cloud verbs from a script cron are refused exactly as they are from an agent session; and job ownership is unchanged, so a script cron reaches only the jobs recorded under its own key (cron_triggeragainst another job stays refused, as it is for agent crons). The general rule this is one instance of: a spawn path that hands a child MCP access must hand it an identity too, because the strict resolver fails closed for writes alone and the omission is therefore silent. - Script-mode
notify()credential:ctx.notify()authenticates/api/send-messagewith anX-Internal-Secretthe child reads from a 0600 temp file the parent writes at spawn (_KIROCREW_SECRET_FILE; the value is scrubbed from the child env by_CRON_ENV_DENY). The in-process cron scheduler runs inside the gateway, sorun_script_sandboxedtakes aninternal_secret_providercallable that returns the gateway's LIVE in-memory secret — the sameapp["local_secret"]the auth middleware compares against — and writes THAT into the file. Deriving the value fromKIROCREW_INTERNAL_SECRETor the per-portrun/gateway-<port>.secretfile (_resolve_internal_secret, env-first then file) is the FALLBACK, correct only for a runner built outside a gateway process (kirocrew cron preview, tests) or a gateway started with no dashboard (--no-dashboard/ API-only). A gateway that re-derived instead of using its own value would mint its child a credential from any stale env var (operator shell, distribution wrapper) or stale file, which the middleware rejects403 Forbidden— the failure mode this provider closes. The provided secret is written only to the temp file; it is never logged, placed in the child env or argv, or put in an error string. - Command mode:
commandfield specifies a shell command to run (mutually exclusive withscript). Stdout captured as result. - Timeout: configurable per job (default 30s for scripts, 300s for commands).
- Safety: scripts must live under
~/.kiro/crew/crons/.is_sensitive_path()blocks credential file access. SEL audit on every invocation. Auto-pause after 5 consecutive failures (_AUTO_PAUSE_THRESHOLD, single-sourced inCronJob.record_failure/record_success). The auto-pause is persistent: an execution-ownedauto_pausedflag (distinct fromuser_paused) is written by_save, propagated by_merge_job_result, and folded into the effectiveenabledderivation in_load— so a failing job stays paused across a daemon restart;enable_job(True)or a later success clears it (SEL-audited transitions). Transient-retry telemetry (last_retry_count, surfaced on the Schedule page) is persisted by the same_merge_job_resultcopy, and describes a run that ENDED: the gateway stages0once the fire-time guards admit a run and the retry chain's outermostfinallyoverwrites it with the real count as it unwinds. A runstop()cancels reaches the merge anyway —stop()deliberately does not mark_cancelled_jobs, the flag the reaper/cancel paths use to skip merging entirely — so it would otherwise persist its staged0and erase what the last completed run recorded._merge_job_result(job, being_cancelled=True)therefore withholds this field, leaving the on-disk value (that last completed run's) intact; the same reasonclear_carried_result()is guarded by the identical flag. A run that ends without retrying still records0, so one flaky run cannot pin a stale count on the job. Concurrent execution guard prevents double-fire. The script body is scanned as well — at authoring time and again at every fire, via_vet_script_fileon the freshly re-resolved path — for credential-path references, protected secret environment variables, exfiltration URLs, and the shared sensitive-path matcher. The body is Python source, not a shell command line, so it is scanned only with whole-body, source-aware detectors (credential path, secret env name, exfil URL) and is NOT routed throughis_deniedoris_sensitive_bash_command-- doing so produced four classes of permanent false denial on ordinary scripts (#7912, #8563, #8643, #8812); see the Cron script bodies are not shell subjects bullet in security.md. The runtime control for what a script may open is the sandbox it runs in. A fire-time denial keeps the job and does not feed the auto-pause counter, so a body the scan misjudges is denied on every tick until it is edited. - Script scan reader: after the existing path and credential checks,
_vet_script_fileaccepts only a regular file. It opens nonblocking withO_NOFOLLOWwhere supported and compares the opened descriptor's identity with the prior non-following metadata check before reading. A FIFO or substituted leaf is refused; platforms withoutO_NOFOLLOWuse the same descriptor identity check. The kernel-reported descriptor path must also equal the previously vetted canonical path and pass the sensitive-path check; an unknown descriptor path or redirected parent is refused before reading. The bounded read keeps UTF-8 replacement and universal-newline handling. The execution-time sandbox remains the control for what a script may open when it runs. - Kind tag:
cron_listlabels each job asscript,command, oragentbased on which mode is configured. - In-flight markers and the loop-stall breaker: every trace a run leaves in the store (
last_run_ts, the history row, its status) is written in the run task'sfinally, which the loop-stall watchdog'sos._exitnever reaches — so a job whose run stalled the loop came back on the next boot as never having fired, was due again at once, and re-ran the crash (an hourly crash loop in the field). Two additions close that.cron_inflight.write_markerdrops<base dir>/cron-running/<job id>.json(job_id,name,started_at,pid, plus the samepid_domain(host + PID namespace) andpid_startidentity the crash-dump header records) when a run starts executing andclear_markerremoves it on everyfinallypath; a marker whose writing PROCESS is gone is therefore exactly "this job was in flight when that gateway died", with no schedule inference. The join to a dump is that full identity, not the PID number (RunningMarker.same_process): a replacement container is PID 1 like the gateway that died in it, and a recycled PID on one host is live while its owner is gone, soowner_alive()is three-valued (crash_dump_store.pid_identity_alive:Nonefor a PID domain this host cannot probe,Falsefor a dead or recycled PID) and a foreign-domain marker is neither swept nor reported — only the dump carrying the same identity may speak for it.CronService.start()then runs_apply_loop_stall_breakeron a worker BEFORE_arm_timer(): it attributes the newest stack-bearing dump (stall_attribution, the same readerkirocrew doctoruses) and, only when the wedged stack is a cron surface AND exactly one abandoned marker carries the dump's PID, parks that jobauto_paused(enabled=False,last_status="error", alast_errornaming the dump and thekirocrew cron resume <id>command), persisted under the store lock and SEL-audited ascron_auto_pause/auto_paused_loop_stall. The dump name is claimed incron-running/.loop-stall-breakerso one crash pauses its job once — a dump stays on disk for a week and a job the operator resumed must not be re-paused on the next boot. Ambiguous evidence (several runs in flight, no marker, a chat or channel surface) pauses nothing; the doctor prints the same attribution for the operator. Abandoned markers are swept after the breaker reads them — and what they said is written first tocron-running/.loop-stall-attribution(the dump name plus the candidate and unrelated markers, no.jsonsuffix soread_markersnever mistakes it for a marker), whichattribute_dumpmerges back in for that dump, sokirocrew doctorand the restart notification, both of which run after the sweep, still name the candidates the breaker declined to choose between. A dump is claimed, and its markers swept, only once the verdict survives a restart. A pause the store refused to persist is NOT claimed and its evidence is NOT swept, so the boot whose store is readable again reaches the same verdict instead of skipping a job that is still enabled and still due. The sweep runs only behind a claim that actually landed (write_claimreports it), and the pause is idempotent per dump — the paused job'slast_errornames the dump and resume keepslast_error, so a lost claim file cannot turn an operator's resume into a second pause on the next boot. Both the marker writes and the breaker run off the loop (asyncio.to_thread), a marker failure never fails a run, and no failure inside the breaker can failstart()— it is a safety net, and losing the scheduler would be worse than losing the net. - Crash-dump byte mode: the Windows dump writer uses
O_BINARYalongsideO_NOINHERIT, preserving header and faulthandler bytes without CRT newline translation. POSIX keepsO_CLOEXEC. This matches the binary reader; it does not establish a cause or fix for missing thread stacks. - The markers are fenced evidence.
cron-runningis onsecurity._CREW_SECRET_LEAVESbesidecrons.jsonandcron-history, because the breaker's pause RESTS on a marker: one the agent could write is an unauthorized "pause this job" that routes around the MCP cron tools, and one it could delete disables the breaker for a crash loop about to recur. Under that fencecron_inflightstill treats its own directory as hostile, for a leaf planted before the fence existed: reads refuse acron-runningthat is itself a symlink or junction (every child open would otherwise resolve inside its target), open each childO_NOFOLLOWand accept only a single-linked regular file within_MARKER_MAX_BYTES(a symlink read lands wherever it points;read_texton a FIFO blocks for ever on the workerstart()awaits, which would hang the scheduler rather than arm it), and writes go throughatomic_write(restrict_to_owner=True), whosemkstempname cannot be pre-planted and whose linked-parent refusal stops a redirected write from landing a marker's bytes on a keystone file. The claim file and the attribution record are read and written the same way.
Operator-Granted Vault Secrets (secret_env / secret_env_pin)
SCRIPT crons (and only script crons — command jobs are refused at every
layer, because a pin over command text cannot cover the helper files the
command invokes) can receive secrets from the encrypted SecretVault
(kiro_crew.secrets) without a plaintext token ever living in .env, the cron
store, or the script. A grant is a per-job map secret_env: {ENV_NAME: vault-secret-name} plus a code pin, persisted on the job and resolved only at
fire time:
- Grant surface is operator-only, but requests are agent-first. The MCP
tool
cron_secret_request(job ownership enforced) lets the agent record a PENDING request — mapping + a pin of the code at request time, written to the separatesecret_env_pending*fields, never the active pair — after which the operator approves or denies.PUT /api/crons/{id}/secretsaccepts{"approve_pending": true}(routes through_promote_pending_grant, which re-verifies the pending pin against the job's CURRENT code and refuses 409code_changedon drift, so an approval never blesses code that changed after the request),{"deny_pending": true}, or a revoke (empty map) — a non-emptysecret_envmap is refused 400direct_grant_removed, because the request->approve flow is the only mint path (see the pin bullet below). The machine/human boundary is enforced IN the handlers, because/api/cronsis a prefix entry in the mixed internal paths: the grant route refuses a provenX-Internal-Secretcaller (request["internal_auth"], 403operator_only) — machines request, humans grant — while the agent-side request path is thecron_secret_requestMCP tool, which records the pending request through the cron store directly (no dashboard endpoint is exposed for it). Granting is additionally owner-only: a dashboard token minted for an allowed Slack user (!dashboard) clears the machine check but is not the vault's owner, so the grant route also requires the shared owner gate (require_owner_dashboard_request, 403owner_onlyotherwise). Both denials are SEL-audited (api_access/denied, operationcron.secret_grant) — a machine or non-owner probing the grant endpoint is exactly the signal the audit log exists to record.GET /api/cronsserializes thesecret_env*metadata fields (names only even for the owner) exclusively into owner-view responses, with every key and value passed through the credential/exfil-URL redaction like the sibling fields — the store is agent-writable and the read path loads these dicts verbatim, so a mapping planted directly incrons.jsoncannot carry credential- or URL-shaped content to the dashboard. For the same reason the grant endpoint checks that both stored grant fields are string-to-string mappings before it snapshots them, refusing a planted list or string with a structured 400malformed_grant_stateinstead of letting the copy raise into a 500. The grant endpoint's own echoes take the same scrub: thesecret_envin its success body and the names listed in itsunknown_secreterror are agent-authored (they came in via the request), and the vault-name slug grammar admits a bare access-key id, so neither is serialized raw. The persistence layer (_update_job_locked) re-validates every grant AND every pending request: env-name grammar ([A-Z][A-Z0-9_]*), the protected-name deny set (_CRON_ENV_DENY,PATH, loader-hijack prefixesLD_/DYLD_/PYTHON, product-internalKIROCREW*/_KIROCREW*), a 16-entry cap, and script/command jobs only — anagentjob is refused because its session would hand the plaintext to the model, defeating the vault's agent fence. - Approval lives EXCLUSIVELY on the owner-gated Schedule page. There is
deliberately no in-chat approval card for a pending grant: an approval
record registered with the generic approval broker is reachable from
resolution surfaces (chat state-approval fallbacks, the approvals
endpoint) whose identity floor is "authenticated dashboard caller", not
"owner", and its
tool_inputsummary carries vault secret NAMES — so a card kept widening the owner-only boundary. The MCP tool records the pending request and tells the agent to point the user at Schedule > job > Secrets, where the pending banner renders and approve/deny are owner-gated server-side. An in-chat surface can return later behind a dedicated owner-only delivery channel. - The pin binds the grant to the code the operator approved.
compute_secret_env_pindigests, in one payload: the HMAC domain (pendingfor an agent request awaiting approval,activefor an operator-minted grant — a pending pin copied verbatim into the active store fields never verifies), the job id (no cross-job replay), the delivery fingerprint (a canonical blob of every agent-mutable field deciding where or whether the run's output is delivered —session_key,silent,channel,thread_ts; binding them one by one invites the next omission, so the pin binds the blob and rewiring ANY of them under a still-valid pin fails the run closed and asks for re-approval), the canonical grant mapping (re-pointing which secrets flow under an existing pin breaks it), and the job's code — the script spec + the jobmessage(the script's agent-updatable arguments, so re-aiming an approved script requires re-approval) + current body bytes. The two domains differ in trust model: a pending pin is an UNKEYED sha256 — it authorizes nothing (only approval mints the pin the runners honour) and it must be computable by the MCP server, whose sandbox hides the.vaultdir; its job is integrity, since approval recomputes it against the current code and refuses on drift. An active pin is HMAC-SHA256 under a purpose-scoped derivation of the existing vault key (SecretVault.derive_subkey("cron-grant-pin")) and additionally binds the job's grant epoch, held in the agent-fenced.vault/.grant_epochs.json: every owner grant WRITE (grant, replace, promote, revoke) bumps the epoch before minting, and every job-removal path bumps it before the store swap (single delete, batch delete, by-owner teardown, one-shot delete-after-run, deferred removals) — a deleted job's record is agent-readable history in an agent-writable store, so without the bump a re-created job carrying the saved record would still verify. The removal bump keys on the id's committed epoch entry, not on the grant fields the record shows at delete time: the agent can clear the fields before deleting, but an id with an entry once had an active pin minted under it and stays replayable until bumped (an id with neither never had one — only approval commits entries — so the epoch file stays bounded across one-shot churn). A formerly valid mapping+pin the agent copied out of the store and writes back after a revoke is therefore always minted under a dead epoch and fails closed. Epoch reads-modify- writes are serialized under a CROSS-PROCESS guard (an in-process thread lock plus an exclusive flock on a dedicated lockfile — removal paths also run from the CLI, so the writer set is no longer one gateway process) with unique-temp atomic replaces that fsync the file and directory (a bump rolled back by a crash would revive every pin under the old epoch), so overlapping grant/revoke requests cannot collapse a revoke's bump. The approval's epoch commit is compare-and-swap on the value its mint peeked: a concurrent bump landing between the store swap and the commit refuses the commit, bumps once more (killing the just-swapped pin), and returns 409grant_conflict— never re-committing the value the concurrent bump minted. A failed bump aborts the job delete (owner paths propagate the error; background ticks requeue and retry), so a delete can never leave a still-live grant record behind. When the held delete is a completeddelete_after_runone-shot, the run path also parks it (persisteduser_paused) and queues it for the deferred drain: the job has already run and the delete is what normally stops it, so a held delete without a persisted pause would re-fire it on every tick until the epoch state healed. Corrupt epoch state fails closed: a file that exists but cannot be parsed refuses every verification, mint, and bump (never silently restarting the counter at 0, which would revive low-epoch pins) — only a genuinely missing file reads as epoch 0. Neither key nor epochs are reachable by the agent's tools or sandboxed cron children, so a forged store entry cannot mint a pin the runner accepts, even on hosts whose OS sandbox backend degrades to "none"; a vault store whose key is missing fails every grant path closed. The request->approve flow is the ONLY mint path: direct grants were removed because nothing binds what the owner's page displayed to what a save would pin — the agent could rewrite the script between render and click and the pin would bless the rewrite. A request pins the code at request time; approval refuses on drift; pins are always computed server-side and any client value is ignored. The endpoint keeps revoke and pending decisions only. Scripts undercrons/stay agent-writeable by design, so at fire timerun_script_sandboxedreads the body ONCE, re-verifies the pin (constant-time compare), and hands those verified bytes to the child over stdin (base64 in a JSON line) — the launcher executes exactly what was verified, never re-opening any pathname a same-UID writer could swap after verification, and a granted script'ssys.path[0]is an empty private temp dir AND both the livecrons/dir and the script's own parent directory are hidden at the sandbox layer (extra_hidden_dirs) for granted runs — the verified bytes travel over stdin so the child never needs the script file — so it cannotimport— or re-add tosys.pathand then import — an unpinned sibling module the agent rewrites after approval (the import fails instead of running with the secrets; ungranted scripts keep sibling imports). The granted launcher file itself is created inside that private dir, never the shared temp dir, and its prelude strips that directory fromsys.pathby value before the first stdlib import:-Iimpliessafe_pathonly on Python 3.11+, so on the 3.10 floor the interpreter still prepends the launcher's own directory, and ajson.pyan agent parked in the shared temp dir would otherwise be imported before the secrets payload is read. A pin mismatch fails the run closed with a re-approve message; the grantable body is capped at the dashboard's script-preview ceiling (_MAX_GRANTED_SCRIPT_BYTES=_SCRIPT_SOURCE_MAX_BYTES), because the approval signs the WHOLE body while the owner reviews the preview — a larger grantable script would let an agent hide executable tail bytes behind the preview truncation and have the operator approve code they never saw (a pinned regression test keeps the two caps aligned). Command jobs cannot carry grants at all: a pin over the command TEXT cannot cover the bytes of a helper file the command invokes (bash helper.shruns whatever the agent last wrote there under a still-valid pin), so every surface refuses them and the command runner fails closed on any grant a hand-edited store smuggles in. - Secrets never ride in an
execveenvironment./proc/<pid>/environis the startup-env snapshot, readable by any same-UID process for the child's whole lifetime, so the runner never puts grant values in the child's spawn env. The script launcher receives them over stdin and applies them withos.environ.updateAFTER its own exec (the kernel snapshot never contains them), behind the protected-key filter (_filter_grant_env), so a grant can never name a product-internal_KIROCREW*key. The launcher also seeds_GRANTED_ENV_KEYSwith the granted names, and_clean_cron_env— the base env for every descendant subprocess the granted child spawns, notablyctx.call_tool's MCP server — strips them: the grant authorizes the approved script body, never the server binaries it calls. - Granted values are scrubbed from child diagnostics. Pattern-based
redaction only recognises known credential shapes, and a vault value has
no required shape — so the runner, which resolved the exact values to
build the stdin payload, replaces every occurrence of them in the child's
stderr tail, the launcher's status JSON (which carries
str(e)from the script's own exception and would otherwise bypass the stderr path), and the bad-stdout diagnostic with[redacted-grant-value]before the result reaches gateway logs or the persistedlast_error. Longest values first, so containment between values cannot leave a recognisable fragment. The launcher JSON'sstatusfield is exempt: it is a closed vocabulary the launcher itself emits (ok/skip/done/report/error) and the caller only compares it against those literals, so it can neither carry a secret nor be rendered — scrubbing it would let a vault value that happens to equal a token rewrite a successful run into an unrecognised (failed) one. - A grant decision is audit-or-deny. Approve, deny and revoke each write
their SEL intent record (awaited, off-loop) BEFORE the store mutates, as a
critical write — synchronous and re-raising, because the default SEL
path enqueues for a background writer and swallows a filesystem failure,
which would make the gate a no-op. An unwritable audit store refuses the
decision with a structured 503
(
audit_unavailable) and nothing mutated, so a grant can never exist without a durable permission record. The terminal success event stays best-effort — an applied grant is already covered by the intent record. - The agent surface cannot enumerate vault names.
cron_secret_requestperforms NO existence check against the vault — a request naming an unstored secret is recorded as pending exactly like a stored one, so guessing names reveals nothing. Existence is validated on the owner-only approval surfaces (grant endpoint and promotion), where the operator sees the vault and the request side by side. - A decision acts on exactly the request the human saw. The agent can
replace a pending request at any moment, so approve and deny both restate
the displayed mapping (
expected_secret_env, plus the request timestamp on approve). On approve both fields are required and compared unconditionally — an optional check is one the agent can arrange to skip, so an approval that omits either is refused with 400invalid_secret_envbefore anything is promoted — and the final swap runs as a compare-and-swap inside the cron store lock (expect_secret_env_pending*→CronPendingMismatch→ HTTP 409stale_request): a request replaced after render is neither promoted nor silently discarded. - A decision acts on exactly the CODE the human saw, too. The request
binding above cannot carry this on its own: an agent can rewrite the script
and re-issue the request, and a Schedule page whose request banner
refreshed while its source view did not would restate the NEW request's
mapping and timestamp — passing both request checks, with the pin verifying
against the new file — while the operator is still reading the OLD code.
The pin proves the code matches the request, never that the approver read
it. So the pending banner loads the script INSIDE itself, keyed to the
request's timestamp (a re-issued request re-fetches; a job-id-keyed view
would keep the stale body under the fresh approve button), keeps Approve
disabled until that exact source has rendered (loading, unreadable, or
truncated all leave it disabled), and the approve call echoes the digest the
source endpoint returned (
expected_source_sha256, the raw-bytessha256of GET/api/crons/{id}/script). The field is REQUIRED — an approval without it is 400invalid_secret_env, because it attests to nothing about the code — and the promotion compares it against the ONE body snapshot both pins derive from: a mismatch is 409stale_source(checked aftercode_changed, so a rewrite without a re-issue still reports the more actionable code-drift reason). Approving therefore requires having been shown the body, which is the premise the whole flow rests on made enforceable rather than assumed. A digest can only attest to what was SHOWN, so the source endpoint also reportsreviewable— true only when the rendered text equals the raw UTF-8 decode byte-for-byte (nothing masked by credential/exfil-URL redaction, no undecodable bytes replaced, not truncated). The banner keeps Approve disabled and says why when it is false, and the promotion RE-DERIVES the verdict from its own body snapshot (never the client's flag), refusing with 409source_not_reviewable: otherwise an agent could hide executable content behind exactly the span the redactor masks and the operator would approve a raw digest over code the display never let them read. - The promoting write also CONSUMES the pending
request in the same atomic swap — leaving it behind would let a concurrent
deny (or a second approval) pass its own compare-and-swap against the same
snapshot and misreport success against the just-activated grant. When the
epoch commit then fails (503
epoch_commit_failed), a compensating write restores the PRIOR active grant (its pin is still valid — the failed commit never advanced the epoch) AND the consumed request (original mapping, pin, and timestamp) so the owner re-approves once epoch storage heals; the restore is CAS-guarded on an empty pending slot, so a newer request the agent posted into the gap is never overwritten — the prior grant is then restored in its own write. The restore is ALSO CAS-guarded on the active fields still holding the just-promoted (dead) grant: a concurrent revoke that cleared them in the gap owns the state, and the compensation yields instead of resurrecting a grant the operator withdrew. - Resolution is in-memory only and fail-closed. The runner resolves
vault names via
SecretVault.get_manyimmediately before spawn and delivers the values over the child's stdin (see the delivery bullet above); the product-internal keys (_KIROCREW_SECRET_FILE,_KIROCREW_DIAL_PORT) live only in the spawn env and are shielded from grants by the protected-key filter. A missing vault entry aborts the run; error messages name the env-var KEY only (the CWE-117 no-echo discipline ofmcp_gateway/secret_uri.py).GET /api/cronsexposes grant NAMES for the UI; plaintext never leaves the vault. Grants and revokes are SEL-audited (cron.secret_grant/cron.secret_revoke, names only).
Auto-pause applies to agent (message) crons too
Auto-pause is not script/command-only. An agent cron's turn signals failure
only by raising, so a turn that returns prose carries no verdict of its own even
when the security gate refused every tool it attempted — the gate outcome has to
be tallied explicitly. A success resets consecutive_failures and clears
auto_paused, so recording one for such a run would keep the guard out of reach
for a job that cannot work.
Only a block that indicts the attempt itself counts toward that budget. A
governance denial does not, in either form it takes: the ceiling refusing a tool
outright, or a built-in rule that the operator disabled and a governance pin put
back into the effective deny set. Both are policy state, and because clearing an
auto-pause never restores enabled, counting them would strand a job that a
later loosening of policy could not revive.
Clearing an auto-pause re-enables the job, because enabled is derived rather
than independent: a reload reconstructs it as not user_paused and not auto_paused. A job left disabled with auto_paused already cleared is stopped
in memory but enabled on disk, so it would stay stopped until the next restart
silently resumed it. A pause the user set is untouched — user_paused is never
mutated by execution, and lifting it is the user's action.
stream_and_collect's on_tool_gate callback reports each tool permission
decision as (tool_title, approved, security_blocked). Both cron agent paths
(single-agent and the sequential agent_sequence loop) tally those decisions
per run and route the outcome through one shared verdict with three arms:
| Run's gate outcomes | last_status | Failure budget | Delivery |
|---|---|---|---|
| Every attempted call security-blocked, none approved | error, reason names the refused calls | record_failure() — the 5-failure _AUTO_PAUSE_THRESHOLD applies | unchanged |
| Some call security-blocked (another ran, or another refusal left capability unknown) | error, reason names the refused calls | neither counter moves | result prefixed with the same reason |
| No security block at all | ok | record_success() | unchanged |
The middle arm exists because the model's prose is its own account of the run:
a turn whose final write was refused still reports the write as done. Without
the arm such a run records ok, alerts nobody, and leaves the refusal only in
the SEL audit row, so the user learns the document was lost by reading the audit
log. The approved calls are evidence the job can work, so the run spends
nothing from the failure budget; the lost call means it did not succeed, so it
does not read as ok and does not reset a failure streak or the failure-alert
dedup the way a success does. _GateTally.refusal_summary() is the one
spelling of the reason, shared by last_error and the result banner, so the
cron row and the notification cannot name different calls.
The tally does not separate the gate's tiers: a title-tier refusal (rm -rf /
as a command) and an input-tier one (a document body the scanner read as a
command line) are both security_blocked, and both are reported, because the
work behind either did not happen.
History is best-effort. CronHistoryStore never lets a history failure reach
the scheduler. Its directory is resolved once by prepare(): usable means history
is fully on, unusable means the store constructs with enabled False, where every
read returns empty and every write is dropped. Usability is decided by the syscalls
the store's own paths issue — an os.stat of the directory plus the lock-file
os.open — because a refused mkdir does not imply an unusable directory
(Path.mkdir(exist_ok=True) consults Path.is_dir(), and pathlib re-raises
EPERM out of that stat rather than reporting False, so the flag cannot absorb a
directory that is denied for reading as well as writing). Deciding it is blocking
I/O, so a loop-bound caller MUST defer it: CronService.__init__ passes
_defer_prepare=_defer_initial_load and CronService.create() runs prepare()
through asyncio.to_thread, the same hop it already uses for _load(); a deferred
store reads enabled False until that completes, so a read racing the prepare
degrades rather than touching a directory of unknown state. At runtime _degrade
disables the store only on a DENIAL (EPERM/EACCES/EROFS), which is a standing
condition; every other OSError (a full disk, fd exhaustion) costs one record and
leaves history on. Callers do not branch on any of this: the store answers
every read with an empty result, so a denied install and a job that has never run
are deliberately indistinguishable over the API today, and the logger.warning
above is the operator's signal. Surfacing the state belongs in the change that
renders it.
Caps are live (CronHistoryStore.reconfigure). The store subscribes to the
cron_history section, and a config write pushes cron_summary_cap,
cron_trace_cap_kb, cron_max_records_per_job and cron_max_index_records onto
the running store. Nothing is migrated: the caps only bound what the NEXT record
write stores and what the next trim keeps, so records already on disk keep the
shape they were written with and the next trim applies the new retention.
cron_trace_cap_kb is re-multiplied on the way in rather than copied, because the
attribute the store compares against is in bytes. The directory decision above is
NOT re-run — usability is a property of the filesystem, not of these caps.
The summary cut keeps the facts (truncate_summary). append() is the ONE
place a summary is truncated: cron.py's run recorder used to slice its own
[:200] on the way in, which cut before the configured cap could apply and, in
the incident on #2836, before the pull-request URL a run had just produced — so
the next session re-derived the same pull request half an hour later. The summary
is the only short, INDEXED record of a run (the trace is capped separately at 50
KB and is not in the index), so what a cut must preserve is what makes the record
findable. Over the cap, the result keeps as much of the head as fits, one ...
marker for the removed middle, every URL that fits — whole, since half a link is
not findable — and the end of the final non-blank line, where a run states how it
finished. A URL is written once — the head stops before any copy the kept URLs or
the outcome fragment already carry — the head is never cut through a URL, the result
never exceeds the cap (so the function is a fixed point on its own output), and input
at or under the cap is returned untouched.
Two rules protect a link against the machinery around it. Only a trailing . or ,
is treated as prose: an address really can end in ! (…/wiki/Yahoo!) or ?, and a
wider set would rewrite a live link into a dead one. And the ... marker is dropped
when charging its four characters would leave the result with no URL at all — a link
that fits the cap on its own must reach the index, and "something was removed" is
worth less than the address it would evict. Whenever a link fits beside the marker,
the cut stays marked. Inside the cap, URLs are reserved BEFORE the outcome fragment
is sized: a fragment allowed half the cap first can consume the room a shorter link
needed, and a link is the one fact the fragment cannot stand in for. They are
reserved in order of each URL's LAST appearance, since a link named again in the
closing line is the run's freshest fact.
What cron_summary_cap decides is ROOM: the cap is spent on links before prose,
newest first, so a single link shorter than the cap always survives while a run that
produced several keeps only as many as the aggregate budget holds (a link longer than
the cap is dropped rather than halved, and a cap at or under the marker's own length
keeps no structure at all).
The default of 200 therefore holds a summary's outcome line plus the newest links
that fit, dropping the oldest link of a run that produced several and the narration
around them; an operator who wants those kept raises the knob — at 500 a realistic
four-link sweep summary is stored whole.
test_cron_history_summary_truncation.py pins both the default and this paragraph.
Only an unconditional security block counts. A governance TOOL_DENY and an
unattended-approval timeout also arrive unapproved, but they describe the policy
state or an absent approver rather than a defect in the job — the same reason the
fire-time governance denials deliberately skip record_failure(). Counting them
would durably auto-pause a healthy job, since record_success() clears
auto_paused but never restores enabled.
Enabled Predicate & Off-Thread Count (_record_is_enabled / count_enabled_from_disk)
The effective-enabled predicate of a serialized job — enabled iff neither
user_paused nor auto_paused, with the legacy !enabled fallback for stores
written before those fields existed — has exactly ONE owner, the module
function _record_is_enabled(). Both _load (the scheduler
deserialization path) and count_enabled_from_disk route through it, so a
future pause-state change cannot land in only one reader and drift.
count_enabled_from_disk() is a read-only parse of crons.json: it counts
enabled jobs via _record_is_enabled and never mutates loop-owned state
(self._jobs, self._last_mtime) or the asyncio timer. The reduction itself
lives one level down, in the module function enabled_count_from_disk(path) — a
sibling of unhealthy_jobs_from_disk that returns (count, loadable) and is the
single owner of the "loadable record AND enabled" loop. Two callers want
different halves of that pair: this method keeps the count and degrades an
unloadable store to 0, because its caller is a status pusher that must always
have a number to render; the telemetry probe
(metrics/inventory_gauges.read_active_crons) needs loadable so it can report a
present-but-unreadable store as a fault rather than as a plausible count. Before
the split each side carried its own spelling of the loop, capped only by the
shared predicates.
It exists specifically
for the dashboard WS status pusher, which needs an enabled-job count off the
event loop. The pusher MUST NOT run list_jobs off-thread: list_jobs calls
_sync() → _load() → _arm_timer(), and _arm_timer both calls
asyncio.create_task (raising RuntimeError with no running loop in a worker
thread) AND cancels the existing timer first — so off-thread list_jobs would
silently stop ALL scheduled cron jobs until restart. A slightly stale count is
acceptable (the caller caches it), and _save's atomic tmp→rename write
guarantees a concurrent reader sees a whole file, never a partial one.
Trigger Command
On-demand job execution via CLI (kirocrew cron trigger <job_id>) and MCP tool (cron_trigger). Delegates to POST /api/crons/{id}/run. The endpoint returns job name and uses create_task for non-blocking execution. If a run for the job is already in flight (tracked in _running_tasks or is_running(job_id)), the endpoint returns 409 {"error": "job is already running"} instead of starting a second overlapping run — the guard is an atomic check-and-set with no await between the check and the _running_tasks assignment, so overwriting (and thereby orphaning) the in-flight task handle is impossible. Unknown job → 404. This 409 propagates to the dashboard "Run now" button, the CLI trigger, and the cron_trigger MCP tool, which surface it as an "already running" outcome rather than a duplicate launch. Both CLI and MCP paths include SEL audit logging.
Compact cron_list MCP Response
Default response is a compact one-line-per-job summary (id, name, status, schedule, next-run, kind, agent, channel, last-status, error/result preview, message preview). Stays under ~30KB for 50+ job registries. Options:
verbose: true— legacy multi-line format (byte-identical to pre-change output)ids: ["<job_id>", ...]— drill into specific jobs with full bodies (takes precedence oververbose, max 200 items)
Security: sanitize-then-truncate ordering enforced for all user-controlled fields (message <=80 chars, last_error <=200 chars, last_result <=120 chars) so credentials straddling truncation boundaries cannot leak as partial fragments.
Schedule Page Templates (Featured Row + Gallery)
Schedule templates live in SCHEDULE_PRESETS (website/src/utils/schedulePresets.tsx). Each SchedulePreset carries an id, icon (lucide), title, description, human-readable cadence, a category (hygiene | quality | security | ops | comms | knowledge — section order and labels exported as PRESET_CATEGORIES), an optional featured flag, and a CronPrefill payload (name, message, and schedule mode + values). Preset prompts follow two authoring rules: a silence-on-no-signal clause ("if none, end silently") so polling-style jobs don't spam notifications — paired with CronPrefill.silent = true, without which the saved job still auto-delivers "No response." every quiet run, so those prompts deliver positive findings via send_message, and stateless trigger anchors (a fixed time window like "failed within the last 30 minutes", a state predicate like "has no reply from this agent yet", or search-before-acting dedup) rather than "since the last run", because cross-run session memory is best-effort and prompt text is copied into the user's saved job at save time. Guardrail sentences in prompts (never push to the default branch, never merge, never echo secret values) are advisory instructions to the agent, not an enforced security boundary.
Templates surface in two places on the Schedule page (/schedule, SchedulePage.tsx):
- Empty state (no jobs): a bottom-pinned "Start from a pre-made schedule" row of the
featuredpresets only (SCHEDULE_PRESETS.filter(p => p.featured)), plus a Browse all templates link that opens the gallery. - Any state: a Templates button next to Add Job in the Jobs header opens
ScheduleTemplateGallery(website/src/components/ScheduleTemplateGallery.tsx) — a modal listing every preset grouped intoPRESET_CATEGORIESsections (empty categories are skipped).
Write-capability indicator: presets whose job performs write actions on the user's repos or issue trackers (pushes branches, opens PRs, comments, labels, closes issues) are tagged writes: true. These render a "Writes to your repos" badge (warn-subtle/warn-fg tokens) on their gallery cards, and picking one shows an advisory notice (role="note") above the seeded create form stating that the prompt's guardrail sentences are agent instructions, not enforced policy. Invariant: a writes: true preset is never featured — the empty-state row is the first surface a new user sees, so it carries only read/report presets; write-capable ones are one click further in, behind the gallery and the badge. A test pins this (never features a write-capable preset). Pairing write-capable presets with enforced deny-rule counterparts, after which featuring them could be reconsidered, is tracked as a follow-up.
Clicking any card (row or gallery) opens the existing create panel with those fields pre-filled — it does not create a job directly. This is driven by an optional prefill?: CronPrefill prop on JobForm: in create mode (no job) the prefill seeds the initial form state (name, message, schedMode, interval, weekDays, weekTime, cronExpr); in edit mode (a job is present) prefill is ignored. The create panel is keyed on the prefill name plus a selection nonce (bumped on every pick) so re-selecting the same preset remounts a fresh form instead of retaining prior edits. The user reviews and saves through the normal createCron path, so a preset-seeded job is an ordinary cron job in every respect except one recorded fact: the create body carries source_preset (the clicked preset's id) and source_template_prompt (the template's prompt at pick time) so the saved job knows which template seeded it and what that template said then (see Template provenance above). That pair is the only lineage a preset-seeded job carries; it drives the template-updated hint and nothing else. CronPrefill.weekDays uses JobForm's grid convention (Mon=1 … Sun=7); a test pins the weekly preset to a Monday (dow=1) cron so a future change to that mapping fails loudly. Cards are rendered as accessible Clickable elements, hug their content, and equalize to the tallest via CSS-grid stretch. In the empty state only, the scroll container uses an 8px bottom pad (pb-2) instead of the default pb-8 so the card row's bottom lines up with the left-nav panel's m-2 bottom edge; the list/table view retains the standard pb-8. Tests derive all expectations (counts, section labels) from the imported preset data, never hardcoded totals.
Imported schedules
The foreign-agent importer accepts only schedules that can be represented by
the native cron model, including finite positive interval and one-shot values.
Every imported job is created disabled (enabled=False, user-paused) in its
initial add_job persistence, with its validated timezone included in that
same call, regardless of the source's enabled state. An import can therefore
never start unattended work; the user must explicitly review and resume it
through the normal cron surface. A record-level timezone is validated and
preserved for string cron schedules as well as object schedules; an object
schedule's own timezone takes precedence when both are present. A present
non-string or unknown timezone rejects the schedule rather than silently
falling back to host-local time.
Schedule deduplication is semantic rather than based on a foreign job id: an
equivalent existing/imported schedule is reported as deduplicated and is not
created again, regardless of created_by. Re-applying a source is therefore
idempotent even when its native ids are unstable. A record is rejected whole
rather than narrowed when it contains command/script/environment execution,
tool filters, cwd, skills, chaining, delivery/channel, repeat/count,
provider/model/agent/session, approval, or sandbox semantics. Unsupported
schedule forms, unknown fields in the record/payload/schedule containers, and
all scheduler runtime state (last/next run, failures, leases, process/session
state) are reported but not copied. Interval values must map exactly to an
integer number of native seconds and remain at least 60 seconds; fractional or
sub-minute values are rejected rather than rounded. A credential detected in a
schedule name or prompt rejects the entire schedule rather than importing a
redacted command.
Hermes cron/jobs.json is admitted before generic projection only when it has
name, prompt, and one native schedule: cron expr, interval minutes, or once
run_at. Cron and naive one-shot wall clocks require a resolvable timezone.
Empty/null skill, script, context, toolset, workdir, provider/model/base URL and
snapshot fields plus no_agent=false are inert defaults; nonempty variants are
active semantics and reject the record. Local delivery, empty origin, and
canonical repeat state (times=null, completed=0 for recurring jobs;
times=1, completed=0 for one-shots) are the only accepted forms. Display,
pause, and last/next-run fields are ignored only after this semantic gate.
OpenClaw's canonical openclaw.sqlite schedule store is safety-checked and
diagnosed as unsupported rather than partially interpreted. Likewise, a
current Codex sqlite/codex-dev.db with the automations table is opened only
after the shared SQLite/sidecar check; RRULE automations are diagnosed as
unsupported schedule semantics and are never approximated into cron.
Foreign-Agent First-Run Import
Authoritative contract: docs/system-specs/modules/onboarding-import.md.
That spec owns the scope (including the explicit non-goals), the destination
mapping into the memory hierarchy, the dry-run contract, the conflict
strategies, and the per-source layout assumptions. This section covers only how
the flow is surfaced through the dashboard and where it sits in onboarding.
The dashboard can scan and selectively merge local setup from five shipped
foreign agents — Codex, Claude Code, Gemini CLI / Antigravity, OpenClaw, and
Hermes — plus any an edition registers through the ImportSourceProvider
seam. Quick is not a source and must not appear as an import option.
The only selectable categories are:
| Category | Import contract |
|---|---|
| Instructions | User-authored rules + persona directives → lessons.jsonl (capped at 50 so import cannot evict the user's own lessons) |
| Memories | Durable memories/preferences through native writers and limits |
| Workspaces | Valid, existing, non-sensitive local directories only |
| MCP servers | Secret-free stdio/HTTP definitions; managed servers protected |
| Skills | User-authored only; source-namespaced and symlink-safe |
| Denied commands | Deny rules only — an allow-list is never imported |
| Schedules | Compatible native schedules; always imported disabled; semantic dedup |
| Settings | Explicit non-security allowlist only; existing KiroCrew values win |
Sessions are not a category. Conversation transcripts are deliberately not
imported — see onboarding-import.md → "Not migrated" for the rationale, and
"Session-import removal" for what that removal covers.
The dashboard MUST present a dry-run review before any apply, and MUST offer the
conflict strategy (skip default, rename, overwrite) for the categories that
support it. Item state is exactly the four writer statuses mapped to three
outcomes (accepted / deduplicated / rejected) plus non-attempted
skipped entries; the UI must not invent a fifth state, and aggregate counts
must be derived from item_outcomes rather than reported independently.
Supported foreign format snapshot
The compatibility snapshot below is current as of 2026-07-26. Import is path-and-shape based; there is no third-party format-version negotiation. A future release remains compatible only while it preserves these recognized paths and record/database shapes. Unknown paths are ignored, while malformed, ambiguous, unsafe, or over-limit recognized records are skipped with diagnostics rather than guessed.
| Source | Root resolution | Recognized layouts |
|---|---|---|
| Codex | CODEX_HOME, else ~/.codex | config.toml (workspaces/MCP/settings); AGENTS.md (instructions); memories/*.md (memories); skills/**/SKILL.md except .system (skills). Session trees, sqlite/codex-dev.db automations, and unstable memory stores are not imported. |
| Claude Code | CLAUDE_CONFIG_DIR, then CLAUDE_HOME, else ~/.claude; ~/.claude.json is also recognized | Root and workspace settings/MCP JSON files (workspaces/MCP/settings, and permissions.deny Bash rules → denied commands); root and <workspace>/.claude/skills packages (skills); root/project memory or memories Markdown (memories); CLAUDE.md and rules/*.md (instructions). Session trees (projects/**/*.jsonl), tasks, and schedules are not imported. |
| a registered lineage source | whatever the descriptor declares | config.json and mcp.json (workspaces/MCP/settings); recent_projects.json, workspace_dir, and project_dir (workspaces); resolved workspace skills and Markdown memory trees (skills/memories); supported memory.db semantic/episodic tables (memories); crons.json and cron/jobs.json (schedules). Read by _scan_lineage_install, which knows this product's own layout rather than a foreign format. |
| OpenClaw | OPENCLAW_STATE_DIR; else OPENCLAW_HOME/.openclaw[-profile]; else ~/.openclaw[-profile], with existing ~/.clawdbot fallback for the default profile | Explicit/default JSON5 config (workspaces/MCP/settings); SOUL.md directive body, MEMORY.md, and USER.md (instructions/memories); explicit, configured, profile, state, and per-agent workspace roots containing skills, memory, or MEMORY.md (skills/memories); cron/jobs.json (schedules). Native session/schedule SQLite stores are not imported. |
| Hermes Agent | HERMES_HOME, then HERMES_AGENT_HOME, then HERMES_CONFIG_DIR; else existing %LOCALAPPDATA%/hermes; else ~/.hermes. Scan the root plus at most 50 non-link directories from a bounded profiles/* enumeration; report overflow without consuming the rest of a large directory. | Exact memories/MEMORY.md and memories/USER.md (memories), plus SOUL.md (instructions); config.yaml or config.yml (MCP/settings); active local skills/**/SKILL.md packages after managed/cache and *-imports/ re-import exclusions (skills); cron/jobs.json (schedules). memory_store.db is diagnostic-only. |
The import is merge-only, idempotent, and provenance-tracked. It never
overwrites an existing KiroCrew item under the default skip strategy;
conflicts preserve the KiroCrew version. rename imports alongside the existing
item under a derived name, and overwrite replaces it only after writing a
restore copy under imports/replaced/<timestamp>/ — both require an explicit
user choice, and neither is reachable by default. A durable ledger records
imported source-item identity so a retry or later manual import reports
already-imported/deduplicated items rather than copying them again. Scan and
apply treat every source tree as read-only and leave it byte-for-byte untouched.
Traversal is bounded by the importer file and directory-entry ceilings. A skill package is rejected when the ceiling prevents observing its complete tree, rather than being imported as a partial package. Schedule records must contain exactly one representable trigger family (cron, interval, or one-shot); mixed triggers are skipped as ambiguous.
Unsupported and secret-bearing items are included only in skipped/reporting
counts and reasons; they are not copied. The excluded set includes conversation
transcripts, hooks, native agent definitions, a foreign persona's identity
role (its directive text is imported as memory instead — see
onboarding-import.md), foreign system prompts, allow-lists, credentials and
secret-bearing MCP fields, security/governance configuration, and
scheduler/provider/runtime state.
Authenticated API contract
All three import endpoints require normal dashboard authentication and the existing mutation protections; none belongs to the unauthenticated boot allowlist:
| Method | Path | Contract |
|---|---|---|
GET | /api/onboarding/import/scan | Return detected supported sources, selectable category counts, skipped/unsupported reporting, and the merge-only marker; do not expose source content, private paths, or secret values |
POST | /api/onboarding/import/apply | Accept selected source ids and category ids, re-scan/validate at apply time, merge eligible items, and return imported/deduplicated/skipped/error summaries |
PUT | /api/onboarding/import/state | Persist completion or skip of the independent import-onboarding gate |
Apply never treats client-provided counts or paths as authority. Source and category ids outside the fixed catalogs are rejected rather than interpreted.
UI ordering and replay
For a new workspace, the full-screen import gate runs before the existing
theme/feature onboarding. That onboarding is six steps: theme → about-you →
Schedule → Apps → Sessions → privacy (the final step; the same telemetry
disclosure and opt-out toggle as Settings → Privacy, rendered in the onboarding
shell — passive, never a consent gate; see
docs/system-specs/modules/metrics.md → "In-product opt-out"). Because privacy
is last, the three tour popovers all advance with "Next" and only the privacy
step offers "Done". Completing the merge or explicitly skipping it sets
dashboard.import_onboarded; the existing dashboard.onboarded flow then
continues independently. When import_onboarded is missing, it migrates from
dashboard.onboarded, so existing onboarded users retain legacy status and do not
receive a surprise first-run gate.
An older browser profile may have only the legacy mc-onboarded completion
marker. When no newer mc-import-onboarded marker exists, the SPA migrates that
completion to both workspace flags before it applies false boot defaults. Once
the newer marker exists, the workspace flags remain authoritative.
After onboarding, Settings → Import can launch the same fresh scan/review/apply flow again. Replay remains merge-only and idempotent.
Dashboard (dashboard/)
The Developer → Config MCP Tool Search toggle saves agent.tool_search as a
boolean through PATCH /api/config/kirocrew. The config watcher refreshes the
defaults used by new sessions.
Modular aiohttp package at 127.0.0.1:5476 (configurable). Split into:
folder_repository.py— chat-folder load, serialized read-modify-write, rollback, full-value write confirmation, and breadcrumb traversal.DashboardStatekeeps its existing folder methods as compatibility facades; request handlers continue to usemutate_folders()/read_folders()and never depend on the repository.slot_buffers.py— live wire-frame delivery and deferred-context buffering, including consumer ownership, bounded release, restore, and corruption logging.slot_queue_repository.py— queued-turn mutation plus the subagent delivery ledger, including stable delivery identities, replay recovery, and bounded orphan bookkeeping.slot_projection.py— read-only source-link indexing/cache and the exact public slot-summary projection consumed by REST and WebSocket clients.slot_registry.py— live-slot, session, and Slack-link lookup; construction reservations; slot-key allocation; and counter reseeding against restored slots.interaction_coordinator.py— approval and question registration, redaction, timeout, audit, cancellation, resolution, and question-card lifecycle.notification_coordinator.py— notification validation/redaction, channel settings, in-memory log and unread state, WebSocket fan-out, and ordered durable storage.dashboard_persistence.py— dirty-slot flushing, open-slot snapshots, and context snapshots while preserving the facade's locks, generations, rollback, cancellation, and close ordering.websocket_hub.py— WebSocket client/subscriber registries, scope checks, serialization, fan-out, browser-event redaction, and bounded close behavior.
_ChatSlot and DashboardState are the stable compatibility facades and the
canonical owners of their mutable containers. This is the intended end state,
not a migration waypoint. The composed components retain no aliases to replaceable
containers and read the current facade state on every operation, because replay,
rollback, cleanup, tests, and integrations replace or inspect those fields and
methods directly. Moving ownership behind a component would require a separately
specified compatibility change.
state.py—_ChatSlotandDashboardStatedata classes; in-memory message buffer (5000 per slot);_ChatSlot.appendstamps every non-wire row withmeta.mid, a per-row delivery identity (random, not a counter — a counter rebased after a restore could reissue an id a restored row already holds, and a colliding id makes a client DROP a real message). It is the only thing that lets a client tell one row's second delivery from a different row that looks identical:tscannot (a coarse OS clock stamps same-tick appends identically) and content cannot (two identical messages are legitimate). Preserved when supplied, so it survives the JSONL round trip; the appended row is returned, so a dual-writer (the cron/workflow/crew injectors) reads the minted id off the return and stamps its durableConversationLogcopy with the SAMEmeta.mid; skipped forchunk/done/streaming, which are never broadcast as achat_messagenor persisted and would pay a uuid4 per streamed token; WS client tracking (_ws_clients,_ws_log_subscribers);_broadcast()sends to both SSE queues and WS clients (dual path — both doors read the SAME note and serialise it through ONE helper,chat_message_frame(note, *, include_metadata), so a field added to the frame cannot reach one transport and miss the other;cls/metaare carried when the note has them and omitted entirely when it does not, never as an empty key.include_metadatanames a property of the TRANSPORT, not a preference: the WS arm passesTruebecause it filters per socket downstream (_send_ws_all→_ws_client_allowed, deny-by-default event scope), whileapi_streampasses it only for a dashboard-user token because the SSE fan-out has NO per-app filtering —metacarriestool_input/ a liveoauth_url/approval_id, so an unconditional pass there would expose it to any app token granted that route whatever itsslots:*scope, the same class as GPT #6789. Enrichment belongs on the door that filters);broadcast_ws()for WS-only events (chat_chunk, chat_done, refine);close_all_ws()for clean shutdown;_slack_linkedbool on_ChatSlot(set fromSessionMap.get_slack_link()on slot init);linked_session_keystr on_ChatSlot(when set,_run_chatuses this as session key instead of deriving from slot name — enables cron slots to share the cron's persistent session);_artifactstr on_ChatSlot(companion chat: the artifact slug this slot is bound to; parsed at slot create against the slug grammar, exposed asartifactinto_dict()/WSslots, persisted in history meta — seemodules/artifacts.md§ Companion Chat);push_artifact_update(slug, version, deleted=False)broadcasts the typedartifact_updateWS envelope from the artifact mutation funnel; approval queue:_pending_approvalsdict +_approval_futures(asyncio.Future per request),request_approval(..., is_background=False)creates future + broadcasts WSapprovalevent,resolve_approval()resolves future (state-level first, then a bare id-match slot scan — safe only for callers that legitimately own the id: native gateway / Slack click / session-scoped handler).resolve_state_approval()is the state-level-ONLY variant with no slot scan and thus no cross-slot authority: the slot-approve handler locates the owning slot under a session-identity guard (linked_session_key/_history_key_for) and its no-owner fallback usesresolve_state_approvalso an ACPrequest_idcollision across unrelated slots can't approve a different slot's pending tool. Timeout auto-denies AND retires the card (approval_resolvedclears the client-injected WS approval card; the session key is the requesting slot when live, otherwise"state"; SEL records outcome"expired"). The coordinator deliberately does not call the permission marker because it owns no slot permission rows and bare ACP ids can collide across the coordinator and chat-runner registries. Interactive sources wait_APPROVAL_TIMEOUT(7200s / 2h, pauses for resume); unattended background sources (cron/heartbeat/taskrunner — passedis_background=Trueby the gateway) deny-fast after_BACKGROUND_APPROVAL_TIMEOUT_SECS(180s / 3 min) since no human is present to respond. Slot titles: untitled slots serialize asNEW_SESSION_TITLE("New Session…") via the_ChatSlot.display_titleproperty — applied at the serialization boundary so brand-new empty sessions and the pre-LLM-title window all read the same, while slots with a real (non-key) title are unaffected;push_slot_title(key, title, *, full=True)broadcasts a title update; callers passfull=Falseto emit only the lightweightslot_titleevent for high-frequency streaming title partials (word-by-word reveal — two characters at a time for a script written without word spaces, where the whole title is one token), then finalize with one defaultfull=Truecall (which also fires apush_slots_update()). Status snapshot:status_snapshot()also carriesbranchandcommit(from_build_info) so clients can detect an actual code update. Sidebar preview:_ChatSlot.to_dict()skipsassistant-role turns taggedmeta.kind=="compaction"(auto-compact notices,/compactbanners) when picking thelast_messagepreview and its OPTIONS, mirroring the frontend'sderiveFollowUpOptionsskip so the sidebar shows the last real messagechat.py— multi-slot chat with per-tab kiro-cli sessions (dashboard:{slot.key}), background LLM streaming (survives browser disconnect), session lifecycle management (active ↔ history), chunk cleanup, tool approval flow. Each tab gets its own kiro-cli process for true multi-agent parallelism — tabs can run tools simultaneously. Sessions idle-expire; on restart, the live tab set is restored from~/.kiro/crew/open_slots.json(snapshotted on every flush + shutdown byDashboardState._persist_open_slots(), replayed on startup byrestore_open_slots()before the legacy mtime-basedrestore_recent_sessions()so long-running tabs survive regardless of message age; after both restore pathsDashboardState.reseed_slot_counter()advances_slot_counterpast the highest restoredchat-<N>-<ts>index so a newly minted tab can't reuse a low index that collides with a restored tab and scrambles the tab↔session binding), and full tab history is re-injected. Cross-tab context (recent messages from other dashboard sessions, capped at 5k chars) is injected at session start for continuity.?ws=1mode returns JSON immediately and pushes chunks via WS._prepare_messages()collapseschunkentries intostreamingrole for API responses during active streaming. Timestamp preservation on resume (originaltsfrom JSONL) and save (single-pass JSONL write preservingtsandcreated_at). Agent persistence:slot.agentsaved to JSONL metadata on close, restored on resume — custom agent sessions survive close/reopen. Pushesrefresh("history")after chat completion. Bidirectional Slack sync: mirrors user messages to linked Slack threads whenslack_clientis available. Stop resets the per-tab session; delete kills the per-tab session viasessions.remove()to free resources. Slash commands:_SLASH_COMMANDSfrozenset skips context injection (sent verbatim to kiro-cli)._BLOCKED_SLASH_COMMANDS(/quit,/exit,/q,/chat,/paste,/reply,/editor,/tangent) are rejected before session acquisition — returns warning message without touching kiro-cli — and are excluded from theGET /api/slash-commandssuggestion payload (both provider paths), so the autocomplete never advertises a command the dashboard rejects./compactis additionally gated as a LOCAL command — above the Slack OPTIONS-expiry boundary and before session acquisition — on the backend'sACP_BACKENDS_COMPACTmembership (themanual_compact_unsupported_backendLLMProvider capability property, peeked off the live session when one exists, else read from theagent.acp_backendconfig the factory would build one with): a backend outside the set (KAS) gets an immediate informational "thebackend manages compaction automatically" assistant message — mirroring the cc_managedrelationship, not an error — instead of a dispatched prompt whose compaction-status wait would strand forCOMPACT_WAIT_TIMEOUT_SECS(#7800); because the gate answers before the turn machinery, no session is created and no pending OPTIONS control is struck through. ACP extension events:_run_chathandlescompaction_status(shows ✅/❌ completion/failure),clear_status(clears slot messages + broadcastsslot_clear),agent_switched(updatesslot.agent+ resets session + broadcastsslot_agent_switch).ws.py— WebSocket endpoint at/api/ws. Single multiplexed connection for all real-time events. Pushes dashboard status every 5s, current slots on connect, log ring buffer replay on subscribe. The lesson/cron counts in the status snapshot refresh only every 30s (_WS_COUNTS_CACHE_TTL) through ONE gateway-wide cache instatus_counts.py(_refresh_status_counts, single-flight — one store touch per TTL no matter how many sockets are open) and are computed OFF the event loop by_load_status_counts(asyncio.to_threadfor bothDashboardState._count_lessons— the JSONL half plus a vector-store SQLCOUNT(*), the same total/api/status/SSE report, fixing the JSONL-only false zero of #7204 — andCronService.count_enabled_from_disk), so slow/large/NFS home-dir latency cannot stall the loop and starve every other WebSocket/coroutine. Each count is guarded independently: a component that has never refreshed successfully is published asnull(= unknown, rendered as a loading skeleton), never an authoritative 0; failures retry on the next ~5s tick until 6 consecutive, then back off to the TTL cadence with one rate-limited operator-visible warning. It deliberately usescount_enabled_from_disk(a pure read) rather thanlist_jobs, whose off-thread_arm_timerwould raiseRuntimeErrorand silently cancel all cron timers (see cron spec § Enabled Predicate & Off-Thread Count). Server→Client:{"type": "dashboard|slots|slot_title|notification|refresh|chat_message|chat_chunk|chat_done|log|refine|sessions_restarting|slot_clear|slot_agent_switch|slot_read", "data": {...}}. Client→Server:{"type": "subscribe_logs|unsubscribe_logs|slot_focused|slot_read"}.slot_read({"type": "slot_read", "slot": "<slot-key>", "read_ts": "<iso-ts>"?}) is the cross-window unread-badge read relay: an owner window sends it when the user reads a slot there, and the gateway rebroadcasts the same frame to owner-authorized sockets only (_handle_slot_read— owner-gated likeslot_focused, non-owner frames ignored — denies leave SEL records, grants deliberately do not, because the owner gate admits only the dashboard user's own sockets and a grant is never a cross-boundary decision; slot key validated as a non-empty ≤512-char string with deliberately NO liveness check, so a just-deleted slot still clears stale badges;read_tsis an opaque ≤64-char watermark relayed untouched — receivers keep badges lit by newer messages). The server keeps no read-state: unread stays a per-window frontend concept. A manual mark-as-unread is deliberately tab-lifetime: the sentinel lives in that window's sessionStorage, never in shared state — a shared copy would surface one window's private reminder in every sibling — so it does not survive tab close and no other window's read can clear it.slot_focused({"type": "slot_focused", "slot": "<slot-key>"|null}) is the resume-prefetch intent signal: focusing a slot whose session is persisted but not live schedules a speculativesession/load(schedule_eager_spawn(allow_resume=True)→get_or_create(speculative=True, speculative_resume=True)), overlapping the multi-second transcript replay with the user reading that history;slot: nullmeans blur (tab hidden). The handler tracks the prefetch task per connection and cancels it on every focus change, blur, and disconnect — rapid tab flipping settles into at most one pending prefetch per socket, and only the task this path armed is ever cancelled, never one from the slot-create/project-set signals. A prefetched session no real turn claims is torn down by a TTL (_RESUME_PREFETCH_TTL_SECS, 600s) viaSessionManager.remove_if_unclaimed, releasing kiro-cli's native per-session lock instead of waiting out the idle sweep. Live-but-unclaimed speculative sessions — fresh and resumed alike — are additionally capped by a HOST-DERIVED allowance,resource_status.prewarm_allowance(): the fixed ceilingPREWARM_MAX_LIVE(3) on an ample host, one session in the tight band (available memory at or under_DEFAULT_PRESSURE_GB), none in the critical band (at or under_DEFAULT_CRITICAL_GB), and the fixed ceiling again when the probe cannot read memory (never worse than before). The allowance is applied as ADMISSION before the spawn (_admit_prefetch: a zero allowance skips the spawn outright and the first message cold-starts; otherwise the oldest still-unclaimed sessions are evicted first so the population never overshoots during the handshake; the probe runs viaasyncio.to_threadbecause it reads procfs; eviction is bounded by the arm generation current when the admitting slot signal arrived, so an admission delayed past a CONCURRENT signal's registration — the worker-thread probe can be held that long under Windows scheduling — refuses instead of evicting the session that signal spawned moments ago and spawning a second one, while a genuinely later signal still outranks and evicts older registrations) and again as EVICTION after registration (_cap_armed_prefetches(cap=allowance)) for an allowance that shrank while the handshake ran. Every eviction is the conditionalremove_if_unclaimed; a claimed session is never touched, so sequential slot signals cannot stack one idle agent process per tab.sessions_restartingevent pushed by_reset_all_sessions()with{"status": "restarting"|"ready"}so the frontend knows when sessions are being recycled. Each provider shutdown is bounded by_SHUTDOWN_TIMEOUT_SECS(5s) viaasyncio.wait_for; on timeout,_sync_kill_providerforce-kills the process tree to prevent leaks (seedocs/architecture/resource-protection.md).slot_clearpushed on/clear(frontend clears messages for active slot).slot_agent_switchpushed on/agentswitch (frontend re-fetches slots for updated agent label). Usesasyncio.ensure_future(ws.send_str())because aiohttp 3.13'ssend_str()is a coroutine. Security:_check_ws_origin()validates theOriginheader before accepting the upgrade — rejects missing or cross-origin requests (allowed:127.0.0.1,localhost,kirocrew.localhost).handlers.py— status, system (live CPU/memory/network), memory CRUD, cron CRUD, lesson CRUD, skills, agent config (save + auto-restart sessions), logs SSE with persistent ring buffer (1000 entries, replays on connect) + queue-based handler (also pushes to WS log subscribers viaensure_future), log level control, session delete, refine status push viabroadcast_wswith throttled chunks (~4/sec).start_timeincluded in SSE/WS dashboard status payload. MCP management: probe cache (_bg_mcp_probe()at startup, 10-min TTL, merges enabled/disabledTools from global mcp.json), server/tool toggle writes to~/.kiro/settings/mcp.json+ syncs to kirocrew.json, bulk toggle-all,_sync_mcp_to_agent()helper.handlers/kiro_prerequisite.py+kiro_prerequisite.py— authenticated first-run readiness surface.GET /api/kiro-prerequisitediscovers viablekiro-clicandidates and checkswhoami; exact owners receive structured platform/install/auth state — all of it DETECTED, never performed, since Kiro Crew neither installs the CLI nor signs in — while authenticated non-owner dashboard users receive only redactedreadyandinitial_setup_completeresults plussetup_allowed=false, so readiness cannot lock them out and host details do not leak. Successful authentication persists an owner-only setup-complete marker; existing installations are inferred only from that marker or non-empty persisted session/history content. Empty directories and zero-byte files created during gateway startup do not bypass first-run setup. App tokens remain denied. The two owner-only POST route (repair-specs) rewrites Kiro Crew's own agent specs and returns200; it is the only write on this surface. Probing is boot-and-explicit-action only. The readiness probe (twokiro-clispawns) runs ONCE per gateway, inwarm_up()shortly after start, and thereafter only on an explicit user action: the gate's Refresh / Check again button (GET /api/kiro-prerequisite?refresh=1, owner-only) or an install/login operation. There is deliberately no timer re-probe and no probe on the send path —session_ready()is a pure read of the latched status, and the polled status endpoint serves that same latched value, so the SPA's 30s poll costs no subprocess. The React gate still polls every second during an operation, every three seconds for a waiting non-owner, and every 30 seconds otherwise; those polls are now free reads that render whatever the latch says. A probe that TIMED OUT is a third condition, not a missing binary.probe_timed_outon the snapshot separates "the spawn never answered" from both "no binary on disk" and "the sandbox refused the spawn". It is its own field rather than a reuse ofsandbox_unavailablebecause a timeout raises no typed sandbox failure, so everysandbox_*field is legitimately empty, and a consumer that mapssandbox_unavailableto a userns remedy must not be handed a slow filesystem to fix with an AppArmor profile. A timeout with a runnable candidate reportsinstalled=True(the binary was stat'd; verification is what failed) andauthenticated=Falseas UNKNOWN, sincewhoamiruns through the same probe path and is never reached on such a host. Known interim gap:KiroPrerequisiteGate.tsxkeys only onsandbox_unavailable/installed/authenticated, soprobe_timed_outhas no UI consumer yet. On a FIRST-RUN host (initial_setup_complete=false) a timed-out probe therefore falls past thesandbox_unavailableintercept into the generic setup shell, which highlights signing in — a remedy that cannot speed up a slow probe. That is not worse than the state it replaced (the same host previously readinstalled=falseand was told to install a CLI it already has), but it is still wrong, and the field is the carrier for the intercept that closes it. An established install is unaffected:initial_setup_complete=truereturns before either branch. A mid-session logout is discovered by the ACP attempt, not by a probe. Because the latch can be arbitrarily stale, turn-starting paths do not gate on it:reject_if_kiro_not_ready()is advisory and always admits (its call sites were removed from chat / regenerate / edit-resend / rewind / side / optimizer /POST /v1/chat/completions). Blocking a send on a stale latch was the stuck case — a user who signed in from a terminal stayed locked out until something re-probed. Insteadchat_runnerhandlesAcpAuthRequiredexplicitly (ahead of the genericAcpErrorbranch, since it is a subclass): it never re-queues (respawning hits the same wall), appends the actionable "not logged in — runkiro-cli login" error card, mirrors that message to a linked Slack thread (preserving the delivery the old pre-turn gate performed), and callsKiroPrerequisiteService.mark_signed_out(). That latch narrowsauthenticated/readyto false without spawning anything and never touchesinitial_setup_complete(so a returning user is never demoted to first-run setup). It only ever narrows readiness, and defers while an install/login operation owns the status. Nothing in the dashboard renders off that latch — the error card is the ONLY sign-out signal the user sees (see "The dashboard does not guide the user to sign in" below). Queued messages and post-fan-out synthesis therefore no longer park on readiness: the readiness-waiter tasks are gone, the successor turn simply runs, and a signed-out CLI surfaces as an error card from that turn. One exception preserves the no-loss rule: when the finishing turn itself caughtAcpAuthRequired, the queue handoff is SKIPPED (_auth_required). Every queued prompt would hit the same wall, so popping them one by one would drain the whole queue into identical failures and leave nothing to resume after the user signs in. The queue is held intact — cards stay visible and individually cancellable — and resumes on the user's next send. This is the no-loss guarantee the deleted readiness waiters used to provide, without a waiter that a latched-stale value could strand. Side turns get the same treatment at their own boundary:_run_side_turncatchesAcpAuthRequiredahead of its generic handler and broadcasts the actionablekiro-cli logintext instead of "(side conversation failed — see server logs)", since the side panel has no other channel to tell the user what to do. It latches the service signed-out too. Two classes still fail closed via the blocking guardreject_if_kiro_unverified(), because neither can use the ACP attempt as its authority: the poll-drivenkiro-clispawn sites (/api/models,/api/sessions/usage) have no turn to carry the failure andkiro-cliauto-opens an interactive browser login when run unauthenticated (andkiro-cli chathangs), so an unverified spawn on a timer opens a window and leaks a process every poll; and the destructive reruns (regenerate, edit-resend, rewind) have already rewritten durable history by the time a turn could fail; andPOST /v1/chat/completionshas no transcript, so an error card would surface as a successful empty completion. A missing or invalid service fails closed in all three. These callers authorize on a FRESH probe, not the latch (kiro_verified_ready→KiroPrerequisiteService.verified_ready, re-probing when the latch is older than_VERIFY_MAX_AGE_SECS= 30s). The latch is written at boot and narrowed only when a chat turn observes an auth failure, so an external logout with no chat turn in between would leave itready=Trueindefinitely — and a staleready=Truehere authorizes exactly the irreversible act the gate exists to prevent (a history rewrite, or a browser-opening spawn). "Probe at boot only" is right for the send path, which risks nothing; it is wrong for authorization. The re-probe is bounded: only these paths call it, never the message hot path, and_PROBE_CACHE_SECScollapses a burst onto one probe. A probe that cannot run denies rather than admits. Both the full dashboard and the headless Slack/API server attach the service to application and dashboard state and close it during runner cleanup; explicit offline test gateways use the service'sassume_readymode.handlers/members.py— the Crew Members page's two routes.GET /api/membersreturns one roster row per GLOBAL crew (name, slug viamembers.slug_for_name, the crew-record fields,bound/slot_keyfrom the member dir'sdm.jsonread off-loop, and an O(1)runningflag); richer live detail rides the already-subscribed WSslotsframes, so the endpoint only fills the cold-start gap.POST /api/members/{slug}/threadis the idempotent get-or-create of a member's pinned DM thread and the ONLY birthplace of member slots: it derivesmember-<slug>for V1 andmember-<slug>.memory-<store>for a new V2 member's store generation (members.member_slot_key). An existing canonical V2 thread keeps its bound key. Existing V1 members keep their memory and thread bindings; creating a separate V2 member does not import V1 messages or native provider context. The persisted DM binding records the current generation, and restore, send, OpenAI-compatible requests and rule reinjection follow that exact key. The endpoint creates the slot withmode="member"and the crew as its agent, and persists the binding viamembers.write_dm_binding(atomic, records the exact crew name so a lossy slug collision stays disambiguated).mode="member"is what keeps these threads out of the ordinary Sessions list (the frontend surface filter never admits it) and it round-trips through history restore, so the pin survives restarts. The pin itself is enforced at every agent-writer: the chat send path and the slot-agent switch endpoint refuse with 409member_thread_agent_pinned, the OpenAI-compat per-request write refuses in OpenAI error shape, and a mid-turnEVENT_AGENT_SWITCHEDon a member slot is vetoed (slot agent kept, session marked for reset so the next turn cold-starts on the pinned crew). App tokens are denied on both routes. Errors carry machine-readablecodefields (invalid_member_slug,member_not_found,member_slot_conflict,member_binding_write_failed). Reopening a RUNNING member thread is read-only. The owner clicking their own member while a turn is in flight is the ordinary case, not a conflict, so the endpoint answers it without writing an assignment and without touching the turn: 200 only when the live slot is still the registered one, its session is the canonical member key, its agent/mode/store still match, the session's published private store matches, the member still privately owns that store (chat_persistence.member_store_ownership_holds— immutable member id, sole ownership, the store database's own recorded identity, AND the record'sowner_membername, because that last field is whatsession_controladmits on), and the persisteddm.jsonbinding still names this slot and member; anything else is 409member_slot_conflict(with the linked-session and differing-assignment messages kept distinct). The same validation answers BOTH running windows — a turn already in flight on entry, one that starts while the opener waits forslot._lock, and one that starts while it awaits the assignment — becauseslot._lockdoes NOT exclude turn dispatch (api_chatreadsslot.runningand publishesslot.taskwithout taking it), so the route re-checksslot.runningafter the assignment too and drops the assignment write rather than publishing onto a live turn. Lock order on this route is dashboard config →slot._lock→memory_store_namespace_lock, matching member create/update/delete (config → namespace); the namespace lock is held from the fresh config load and ownership validation throughbind_session_execution, so a member deleted, recreated or repointed mid-request is refused instead of published. Member system prompt (four layers, distinct ownership): a member DM turn passesmember=slot.agent(the crew name — distinct fromagent=, which is the resolved TEMPLATE) intobuild_message, andContextBuilder._build_member_sectioninjects a[MEMBER IDENTITY]block right after the[CURRENT AGENT]identity formode="member"sessions only. Layer 1 (identity) is DERIVED from the crew's registered config (name, description, triggers) so a crew with an empty description still gets a floor; layer 2 ([HOW YOU WORK], the module constant_MEMBER_HOW_YOU_WORK) is the product-owned working protocol — worker-not-Q&A-bot, front-desk-vs-workshop dispatch, the four-rung stuck ladder (different approach → alternative around the wall → escalate only at a permission/reachability/one-way-door wall → park and continue), zero-context escalation format with subagent validation, quiet-run reporting, and briefing self-maintenance; layer 3 ([PERMANENT RULES]) is USER-owned, stored as JSON ({member, slug, rules}) attrust/member-rules/<slug>.jsonunder the keystone-gatedtrust/subtree so the member's own file tools cannot rewrite its safety boundary — the payload records the EXACT crew name (slugification is lossy, same reasondm.jsondoes) and the read is name-scoped, so a colliding crew name reads the shared file as "never set" rather than inheriting another member's boundary; an EXISTING file that cannot be read/parsed raisesMemberRulesUnreadablewhich PROPAGATES and aborts the member turn (degrading to an ordinary session would let a member the user bounded run with no bounds — the one layer where degrade is fail-open); refused-not-truncated atMEMBER_RULES_MAX_CHARSon write, empty write deletes; layer 4 ([CURRENT ASSIGNMENT]) is MEMBER-owned working memory read from the agent-writablemembers/<slug>/briefing.md, injection-capped atMEMBER_BRIEFING_MAX_CHARSwith a visible truncation marker — and because the file is agent-written and read with the GATEWAY's privileges, the read refuses a symlink leaf at open time (O_NOFOLLOW; on platforms WITHOUT the flag — Windows — the read fails CLOSED to "no briefing", since a check-then-open probe is exactly the TOCTOU an agent-writable path invites), opensO_NONBLOCKand rejects non-regular files viafstat(an agent-planted FIFO would otherwise block the open forever and hang the member's turn) so a briefing repointed at a trust/ payload cannot enter the prompt, and reads at most(cap+2)*4bytes so an arbitrarily large briefing costs a bounded allocation. Every VARIABLE payload the section frames (description, triggers, rules, briefing) is scrubbed of forgeable member-authority markers (_MEMBER_MARKER_RES— both the genuine variable-tail forms and the exact closing-bracket spellings; detection runs on a normalized view — NFKC first, so fullwidth/compatibility glyphs collapse to ASCII, then Cf dropped and dashes folded — but the rewrite is span-local in the ORIGINAL text via an origin map, so legitimate fullwidth paths, emoji ZWJ sequences and prose dashes outside a forgery reach the member byte-exact; if the span-scrubbed result still trips any pattern on the whole-string normalized view the scrub fails closed to injecting that fully-normalized view with every match substituted) BEFORE the genuine headers are minted around it, so a forged[PERMANENT RULES — …](or bare[PERMANENT RULES]) planted in the agent-writable briefing cannot render as the user-owned layer; the patterns are deliberately NOT in_STRUCTURAL_MARKER_RES, whose scan covers the session-context tail CONTAINING the genuine section. The member section is also re-injected on the post-compactionneeds_reinjectionturn (beside the skills index), re-reading the CURRENT briefing and passed through_neutralize_structural_markers(that path has no session-context tail scrub; the genuine member headers are not in_STRUCTURAL_MARKER_RES, so they survive) — without it a compacted member thread would run with no identity and no permanent rules. Precedence is the injection order with one stated exception: layer 3's header explicitly outranks the whole section — the working protocol above included — so the user's safety boundary is never formally outranked by product prose. The rules layer is prompt-level STEERING, not runtime enforcement: nothing at the PreToolUse gate reads these rules, so any UI over them (the eventual rules editor included) must present them as instructions the member follows, never as enforced policy — governance profiles are the enforced path.GET/PUT /api/members/{slug}/rulesis the rules layer's ONLY write path (a human dashboard action: app tokens denied; GET requires the exactmemberquery name like the activity endpoint and answers""for absent rules but 500rules_unreadablefor an unreadable file; PUT off-loads config load, requires slug-match + a registered crew, and REFUSES with 409rules_slug_ambiguouswhen two registered crews collide onto the slug — one file per slug, so either save would overwrite the other's safety boundary;member_slug_mismatch/member_not_found/rules_too_long/missing_rulescodes (theruleskey is REQUIRED — an omitted key must not read as the documented empty-string clear); successful reads and writes emitallowedSEL api-access events (members.rules.read/members.rules.write— the rules are the owner's private safety boundary, so disclosure and change both leave a trace); a successful write also flags the thread's sessionneeds_reinjection— best-effort — so a WARM member session picks the fresh rules up on its next turn instead of running under the old boundary until a compaction or cold start).handlers/source_providers.py— validates GitHub PR and GitLab MR URLs, delegates authentication togh/glab, normalizes metadata/files/comments/reviews/checks, recursively redacts provider strings, enforces subprocess and aggregate-payload limits, and maintains separate bounded caches for full source payloads and lightweight sidebar check state. Both caches age an entry by the lifecycle it describes: an open PR/MR by the short TTL (_CACHE_TTL_SECSfor the payload,_CHECK_TTL_SECSfor the chip), a merged one by_TERMINAL_TTL_SECS(six hours) and a closed one by_CLOSED_TTL_SECS(one hour — it can be reopened and keeps accruing discussion), because re-reading a finished pull request on the open-PR cadence for as long as its chip stays in a sidebar was one provider subprocess per finished PR per minute, forever. The payload TTL is decided from the payload itself through the same_project_statethe chip projection uses (_full_payload_ttl), so the two caches cannot disagree about whether a URL is finished; the explicit refresh button and mutation invalidation still bypass both, and the turn-boundary force (request_check_refresh_now) still re-reads a closed chip (an agent can reopen one) but never a merged one (_chip_refresh_due). Those lifecycle clocks govern the chip cache and the full payloads that have no cheaper read (GitLab, registered plugins). A github.com full payload is instead REVALIDATED past the open TTL whatever its lifecycle:_revalidate_pull_requestfirst issues small conditional REST GETs throughgh api -i -H If-None-Match—issues/{n}(its ETag follows the pull request'supdated_at: title/body/labels/lifecycle including a reopen, a push, reviews, comments) and, for an OPEN pull request only,commits/{head_sha}/check-runspluscommits/{head_sha}/status(CI hangs off the commit and never movesupdated_at; check runs and legacy commit statuses are separate resources and the rollup renders both) — and only when any probe answers something other than304does the fanout run; all-304 re-stamps the cached entry instead. So post-merge comments and a reopen reach the panel within one open TTL for one rate-limit-free request. It is strictly 304-only: the first probe of a URL has no validator, answers 200, and only LEARNS the ETags (_REVALIDATORS, bounded, the two commit-level validators scoped to the head sha so a push cannot reuse the old commit's), a failed probe is "unknown", and nothing is ever judged unchanged by comparing bodies. Validators returned by an all-304 are committed at once; those returned by a 200 describe a payload the cache does not hold yet and are committed only after the full read that follows has succeeded, so a failed fanout can never pair the old payload with new validators (which would make every later probe re-stamp it as current). Re-stamping is also capped: each validator set remembers when its payload was last read in full (read_at), and past_REVALIDATED_MAX_AGE_SECS(the merged TTL, 6 h) one full read runs without probing and the validators are dropped so the next cycle learns a fresh set — the probes rest on GitHub moving the issue ETag for every rendered field, which the API does not promise, so a coverage gap degrades to bounded staleness instead of unbounded.pulls/{n}is deliberately not the probe (its ETag churns on the embedded repository counters), GitHub's GraphQL API — whatgh pr viewspeaks — has no conditional requests, an authenticated 304 costs nothing on the primary rate limit, andghexits 1 on a 304 so_parse_conditional_getreads the status line rather than the exit code. The merge pair moves no validator and stays with the chip protocol; an explicit refresh, a mutation invalidation, GitLab and registered plugins never probe. Full source payloads carry a normalized merge-state pair shared by both providers:mergeableismergeable|conflicting|unknown(''when the provider omitted it) andmergeStateStatususes GitHub's lowercased merge-state vocabulary extended with a GitLab-specific value (clean|dirty|behind|blocked|unstable|draft|need_rebase|unknown|''). GitHub mapsmergeable/mergeStateStatusfromgh pr viewdirectly; GitLab derivesmergeablefromdetailed_merge_status(falling back to legacymerge_status) and mapsdetailed_merge_statusonto the shared vocabulary (conflict→dirty,need_rebase→need_rebase(kept distinct: on fast-forward-only projects a merge commit cannot unblock the MR, so it must not be conflated withbehind), approval/CI/discussion/policy/security gates (includingstatus_checks_must_pass,policies_denied,security_policy_violations,merge_request_blocked)→blocked,ci_still_running→unstable, unrecognized non-empty values→unknown). Both providers compute mergeability lazily: the first read of a pull request they have not evaluated recently answers "not known yet" (GitHubUNKNOWN, GitLabchecking/unchecked) and is what starts the computation, so a single read reports a conflicting pull request as having no merge blocker at all. Each full fetch therefore re-reads the merge fields alone —gh pr view --json mergeable,mergeStateStatus/ the GitLab merge-request endpoint, which is the only placedetailed_merge_statusis exposed — at most_MERGE_STATE_REREADS(2) times spaced_MERGE_STATE_REREAD_DELAY_SECS(0.8s) apart, dispatched inside the existing secondary fanout so the wait overlaps calls the request was already making. A value that is empty rather than unknown is never re-read (the provider omitted the field; re-reading cannot settle it), and an unsettled, failed, or malformed re-read degrades tounknownrather than raising — an unknown merge state costs one banner, never the panel. Settledness is judged on the pair, not onmergeablealone: GitLab settlesneed_rebaseand its branch-protection gates in the detail field whilemergeablestaysunknown, so keying onmergeablewould re-read a state the provider had already answered and then discard it. Provider CLIs run throughsandboxed_spawn_argv(..., mode="standard")using absolute paths only: an explicit absoluteKIROCREW_GH_BIN/KIROCREW_GLAB_BINoverride, else the fixed well-known install dirs, else the ambientPATH(provider_executable_candidates()). The trust policy itself (candidate dirs, validation, strict mode) is single-sourced inkiro_crew.github_runnerand re-exported here, so this panel, Issue Radar, and Code Review Sage can never drift apart. The default policy is if the CLI works in the user's terminal, it works here: the gateway user's OWN install is accepted — Homebrew/Linuxbrew/asdf symlink layouts included — and only provenance the user did not choose is refused, namely a binary (or ancestor) owned by another unprivileged account, anything world-writable (a world-writable directory is tolerated only when sticky —/tmp-style 1777, where only the owner may replace an entry, so the ownership check still decides), and anything inside the agent-writable project checkout or workspace root (github_runner.agent_writable_roots(), the one substitution vector the model controls; the same rule codex applies to its own sandbox helper). A gateway running as root is refused outright in BOTH modes, because every process it spawns — the agent's own shell included — would be root too, which makes the ownership and agent-tree checks vacuous. Requiring a root-owned copy instead made every stockbrew install ghfail and pushed users into asudo cpritual for a CLI they had already installed and authenticated, so provenance was traded for containment: the provider child still gets only a minimal provider-scoped env, and every spawn is SEL-audited.KIROCREW_PROVIDER_BIN_STRICT=1restores the historical hardened rule for shared or multi-tenant hosts — canonical, symlink-free, root-owned and non-writable through every ancestor,PATHnever consulted — and its setup error then names the privileged/usr/local/libexec/kirocrew/(or/usr/libexec/kirocrew/) copy to provision plus the override to point at it. The child receives a fixed systemPATH, never the gateway/workspacePATH;resource_limit_preexec(), a minimal provider-specific environment, and host pinning remain enforced, and unrelated gateway/AWS/Slack credentials are not inherited.github.comandgitlab.comare always accepted; a self-managed GitLab instance is accepted only when the URL's exacthost[:port]is a member of the operator'sdashboard.gitlab_hostsallowlist (deny-by-default, config-only, never browser-supplied, no suffix or wildcard matching,www.not stripped, and a portless entry does not authorize an arbitrary port; an explicit:443is treated as absent on both sides so it matches the browser URL API, which drops the default HTTPS port). The allowlist is served from a process-cached snapshot refreshed at most once per 30s byensure_gitlab_hosts_loaded(), which every async entry point awaits before validating a URL: the config read runs in a worker thread, never on the event loop, and the synchronous accessor that URL parsing and slot serialization use only reads the cached snapshot (empty before the first refresh, which fails closed). Refreshes are serialized behind a lock with a post-acquire freshness recheck, so a loader holding the pre-revocation config cannot install its snapshot after a newer one and re-admit a just-removed host for another interval. Each content change bumpsgitlab_hosts_generation(), which_ChatSlot._pr_source_links()folds into its per-slot cache key alongside the message revision -- the synchronous scan can run before the first load, and without the generation that cold-snapshot rejection would stay memoized until the next message mutation, leaving a self-managed chip missing (or a revoked one present). Aglabcall to a self-managed host dropsGITLAB_TOKENfrom the child environment: that variable carries no host binding, so forwarding it would hand a gitlab.com credential to the self-managed server; such hosts authenticate from their per-host entry in glab's own config, still reachable viaGLAB_CONFIG_DIR. gitlab.com keeps the ambient token.hostis a REQUIRED argument for everyglabinvocation rather than a defaulted one: an omitted host raises instead of silently resolving to gitlab.com, so no future call site (including a mutation endpoint) can read or write an allowlisted self-managed MR on the public instance at the same project/IID. Because slot source-link extraction is synchronous and cannot load the snapshot itself, the owner WebSocket awaitsensure_gitlab_hosts_loaded()before its firstserialize_slots()and again once per refresh round, pushing a slots update whenever the generation changes -- otherwise a newly authorized (or revoked) self-managed chip would wait for an unrelated message mutation.GET /api/chat/slotsperforms the same warm-up so a cold direct fetch is not missing those links either. A full payload'surl/numberidentity always comes from the validatedSourceRef, never from the provider's echoedweb_url/iid/url: the browser submits that url back for refresh and thread resolution, so a hostile or compromised instance echoing a different merge request could otherwise steer an owner-authenticated call at an unrelated one. Eachglabinvocation pinsGITLAB_HOSTto the hostparse_source_urlauthorized for that URL and re-checks it against the allowlist at spawn time, so a configured self-managed default inglabconfig cannot redirect bare API paths and a caller that skipped URL validation is denied rather than reaching an unauthorized instance. The dashboard-config GET exposesgitlab_hostsread-only (absent from the PUT allowlist) purely so the client knows which pasted links to surface as source tabs. Provider stdout is section-bounded at 1 MiB for metadata/checks, 2 MiB for discussions, and 4 MiB for diffs/changes; normalized full payloads are capped at 8 MiB. A global four-command semaphore covers full-source, direct-check, resolve, and sidebar work. Unique direct full/check tasks share a 16-task ceiling and a conservative 128 MiB retained-byte budget: full tasks reserve 64 MiB and checks tasks reserve 8 MiB until the underlying task terminates. Same-URL callers coalesce before admission, detached stale full fetches retain their leases, and a stale pre-mutation fetch plus its required fresh successor can coexist at the exact aggregate ceiling. Successful thread resolution advances that URL's cache generation and detaches older shared fetches so pre-mutation results cannot refill the cache or satisfy the post-resolution refresh. Secondary metadata endpoints degrade independently so core source details remain available, but every failed files, commits, discussion/thread, pipeline, job, or GitHub check-rollup request is named inpartialSectionsbefore its data falls back to an empty section. GitHub'sstatusCheckRollupis never bundled into anothergh pr viewfield set:ghresolves a--jsonfield set atomically, so a fine-grained token without Checks read access would lose the fields it WAS authorized for — the full payload, the sidebar chip, and the checks poll all read the rollup through one isolated query (_github_rollup_read, which also carriesheadRefOid), and a rollup read that fails or that straddled a push (its head sha differing from the core read's) markscheckspartial with an empty list instead of failing the read or rendering another commit's checks; provider page limits and overflow evidence use the same deduplicated markers. Native Windows is not refused by a platform check of its own: it has no OS-level provider sandbox backend, so it reaches the same no-backend policy a backend-less Linux host does —sandboxed_spawn_argvfail-closes and the read is refused unless the operator setagent.sandbox_allow_unsandboxed_exec, whose refusal text names that opt-in. With the opt-in set, provider commands (reads and the PR/MR mutations alike) do spawn on Windows, under the same non-sandbox bounds every host keeps: the{gh, glab}allowlist, the validated absolute executable, the minimal provider env with a fixed systemPATH, host pinning, the output caps, and the SEL audit. Every provider execution emits credential-free SELinvokedpluscompleted/failedlifecycle events; policy and provenance rejections emitdenied. The criticalinvokedappend is shielded and awaited on a worker thread before spawn, preserving audit-or-deny ordering without blocking the gateway event loop; cancellation waits for that worker, pairs a landedinvokedevent withfailed/request_cancelled, and never spawns, while other terminal events are best effort. Events contain only the logical provider and coarse reason, never argv, URL, repository, output, environment, credentials, thread id, or exception text. Sidebar refreshes run outside slot serialization with inflight deduplication, a 16-task pending ceiling, one-TTL overflow backoff, and a 512-entry status cache. Scheduling requires an exact dashboard-owner request. Cached check state is otherwise only repopulated at WebSocket-connect and slots-GET time, so each owner WebSocket connection additionally runs a background refresh driver that re-schedules refreshes for its currently-rendered sidebar chip URLs (DashboardState.source_link_urls()— the first_SERIALIZED_SOURCE_LINKS_PER_SLOTlinks of every slot) once per cache TTL (CHECK_STATUS_TTL_SECS, sleeping exactly one TTL so each round finds the previous round's entries just expired — one provider fetch per URL per TTL, coalesced across tabs by the inflight dedup; a merged or closed chip is skipped by the same_chip_refresh_duegate until its terminal TTL lapses); without it a PR merged or a CI run completed after page load would keep its stale connect-time chip until a full reload. The driver is owner-gated (never created for non-owner connections), cancelled with the connection, and advances a per-round starting offset by the pending admission cap (CHECK_STATUS_PENDING_MAX) each round so that when the number of stale chips exceeds the cap the admitted window rotates across every chip withinceil(len/cap)rounds instead of the same slot-order prefix winning every TTL and starving newer slots' chips indefinitely. Each round's work is individually guarded so a transient failure is logged and the loop continues rather than the driver dying silently and reverting to frozen chips. Generic slot serialization and broadcasts omit cachedstate/ci; owner HTTP and WebSocket snapshots opt in, and changed statuses trigger a debounced generic update followed by an owner-WebSocket-only overlay._ChatSlotretains only the first 64 unique durable source links and stops scanning at the cap;to_dict()exposes up to three sidebar chips as{url, provider, number}to all authenticated callers and adds the whole cached chip-status entry —{state?, ci?, mergeable?, mergeStateStatus?}— only at an owner-authorized serialization boundary.stateisopen|draft|merged|closedandciisrunning|passed|failedwhen known; the merge pair uses the shared merge-state vocabulary above and is present only once settled. Both providers derivestatewith terminal states outranking draft (a GitLab MR keepsdraftset after being closed as a draft, so draft is reported only while the MR isopened), and a provider state outside the known set (e.g. GitLablocked) yields nostaterather than a mislabeledopen.cirolls a GitLab pipeline status up the same way GitHub's check conclusions roll up: any failure fails, anything still in flight runs, and a terminal non-failure -- including a whollyskippedpipeline -- passes, so a skipped pipeline settles instead of spinning. Pipeline-levelmanualis the one deliberate split from the job-level bucket: a pipeline inmanualis blocked awaiting a required manual job, so it reportsrunningrather than green, while a singlemanualjob among finished ones still buckets as skipped -- unless it carriesallow_failure: false, which makes it a required gate and therefore pending. GitLab's chip status readshead_pipelinefrom the merge-request payload and falls back to the MR pipelines list only when that field is absent. GitHub's chip refresh pairs its core-field read (state,isDraft,mergeable,mergeStateStatus,headRefOid) with the same isolated rollup read, concurrently; only the core read is load-bearing — a rollup that fails or describes a different head degrades to an internal ci-unavailable marker that_refresh_check_statusstrips before caching, keeping a previously knownciglyph rather than erasing it (the same keep-known posture the full-payload write-through applies whenchecksis partial), while a successful rollup with zero checks still clears a stale glyph. Because the chip entry is spread whole, a refresh in which only the merge pair settles also counts as a status change and triggers the same debounced generic update plus owner overlay. This module also owns the TRANSCRIPT-SEARCH seam for provider ids:SourceProviderPlugincarries an optionalsearch_ref(token) -> (canonical, alts) | Nonehook (getattr-discovered, likepath_markers()),source_search_ref()fans out across the registered plugins and asks each registered plugin until one answers and hands that answer through UNVALIDATED, without readingaltsitself (the shape checks belong to the single normalizer, whose guard also wraps this collector because it IS the resolver core calls, while a raising hook is caught per provider here); the FIRST plugin to ANSWER wins, for every token shape, and the spellings it contributes are bounded once in core by_MAX_SEARCH_REF_SPELLINGSrather than by a collector-side ceiling that would drift from it. There is deliberately no cross-plugin merge: one would exist to serve two registrants holding a real item at the same number, and this repo registers no provider at all, so it would be surface no code path can reach — additive if a second registrant ever appears.register_source_provider()additionally publishes that collector downward intohistory_search.register_search_ref_resolver— dashboard → core, at registration rather than from a route handler, so the non-HTTP callers ofparse_search_queryanswer identically. Casefolding, dedup and the per-provider spelling cap belong tohistory_search._provider_search_ref, the single normalizer;reset_source_providers_for_tests()unpublishes the resolver alongside the plugin registry.server.py— app factory, route registration, startup, SPA fallback middleware for React Router,/api/wsWebSocket route, token auth middleware, loopback-only binding (127.0.0.1). Fires background MCP probe at startup viaasyncio.create_task(). Honorsagent.yolo=trueconfig at startup via_apply_startup_yolo()— attempts SEL audit first and only activates dashboard YOLO (6h TTL) if the audit succeeds (fail-closed).
Security
- Network binding: dashboard binds to
127.0.0.1only (loopback), never0.0.0.0. Prevents unauthenticated remote access from network-adjacent attackers. - Token authentication:
dashboard/token_auth.pyvalidates a signed HMAC session token on every request. Pure stdlib — no external deps. Returns401if invalid. The enterprise SSO status surface (sso_status.py) is an inert stub in the OSS build — no cookie validation is performed. - WebSocket origin validation:
ws.py:_check_ws_origin()validates theOriginheader on every WebSocket upgrade request before accepting the connection. Rejects missing Origin (non-browser clients) and cross-origin requests. Only allowshttp://127.0.0.1:{port},http://localhost:{port}, andhttp://kirocrew.localhost:5476. Prevents cross-origin WebSocket hijacking where a malicious page could connect tows://127.0.0.1:5476/api/wsand passively exfiltrate conversation data. - Token authentication:
dashboard/token_auth.pyvalidates the signed HMAC session token on every request including WebSocket upgrades. Pure stdlib — no external deps. Returns401if invalid. The enterprise SSO status surface (sso_status.py) is an inert stub in the OSS build — no cookie validation is performed. - CSRF protection:
server.pyCSRF middleware validatesOriginheader on all non-safe HTTP methods (POST, PUT, DELETE). Same allowed origins as WebSocket. - Source-provider mutation ownership: GitHub/GitLab source reads may use a signed machine-local bootstrap subject (
local-app/local-startup) before an owner is configured, but every provider mutation remains owner-only. When one of those signed local dashboard sessions reaches a mutation with no configured owner, the gateway still returns403but labels the already-denied response withcode: "owner_not_configured"; the Changes panel, review threads, and Code Review Sage replace the server's English fallback with localized guidance and link to Settings → Messaging Channels → Slack. App tokens, unauthenticated requests, and non-local subjects retain the generic forbidden body so the response does not disclose the install's owner state. - Prerequisite mutations: agent-spec repair is the ONLY one left — there is no install route and no login route (see the gate section), so the only other endpoint is the status read. Repair accepts no body-controlled command, URL, argument, or path, and requires an explicit empty app claim, so an app token stays denied even if its manifest declares the route prefix. Invoked actions are critical-audited before spawn and terminal outcomes are best-effort audited.
- Static assets (
/assets/,/static/) bypass auth check.
Session Lifecycle
Each chat tab gets its own kiro-cli session keyed by dashboard:{slot_key} with a corresponding JSONL file (~/.kiro/crew/history/dashboard:{key}.jsonl). Sessions move between active and history:
- New: user clicks + → creates slot with unique key →
get_or_create("dashboard:{key}")assigns a kiro-cli session (cold start) - Chat: messages accumulate in-memory (
slot.messages, max 5000); kiro-cli session is per-tab so tabs run tools in parallel - Close: user clicks ✕ → slot saved to JSONL (overwrites, preserves
created_at) → per-tab session killed viasessions.remove()→ removed from active → appears in history - Resume: user clicks history item → JSONL loaded into slot (same key) → new kiro-cli session created with history re-injected (if already active, returns existing — no duplicate)
- Close again: saved back to SAME JSONL file → same history entry (no duplicates)
- Gateway shutdown: all active slots saved to JSONL
Guarded metadata writes: slot metadata endpoints mutate the live slot before
their history write. While a write is pinned to an authorized transcript key, the
periodic unpinned flush skips that slot; this prevents a rebind from making the
provisional value durable in a different transcript. A refused pinned write rolls
back its endpoint-owned value before a later flush may resume.
7. Idle expiry: per-tab sessions expire after session.timeout_secs (default 60 min) like any other session; on next message a fresh session is created with history re-injected
Cross-tab context: removed (budget redistributed to other caps). Previously injected recent messages from other dashboard tabs; this block was eliminated and its 6,000-char budget absorbed into the raised memory/lessons caps above.
Context budget (context.py): ordinary startup uses the fixed Crew background admission defined in memory-skills-hooks. It retains complete explicit preferences and applicable rules rather than slicing the joined prompt. Rules of uncertain provenance remain intact; old activity is retrieved on demand through the same store-bound recall route, including bounded V1 notebook/history snippets. Outer replay, the safety contract, following-interaction guidance and the current request are measured separately from optional background. Neither a larger model window nor skills.lazy_load adds background capacity.
Per-turn timeout (constants.py:CHAT_TURN_TIMEOUT): every _run_chat invocation is wrapped with asyncio.wait_for(timeout=CHAT_TURN_TIMEOUT) regardless of dispatch site. This applies uniformly to: primary user-typed turn (chat_handlers.py), queue-drain (chat_runner.py finally block), cron injection (handlers/messaging.py), Slack/dashboard nudge (slack/gateway.py autonudge path), subagent injection (slack/gateway.py two paths), and the post-fan-out synthesis turn (chat_runner.py drain/idle branch — fires one consolidated synthesis after the last sub-agent of a fan-out completes). The structured dashboard-monitor path runs _run_chat inside an authorization coroutine passed to spawn_guarded_turn; authorization is rechecked after the background permit and the helper still owns the same ceiling. The cap (14400s, 4 hours) is sized to match the inner ACP _DEFAULT_PROMPT_TIMEOUT so the dashboard layer does not bound below the transport; four hours is the longest single turn the shipped budgets can legitimately produce (the task runner's 90-minute test command plus a fix and a re-run, or a blocking subagent wave at its 2h wait cap), and anything longer belongs to the loop mechanisms, which end the turn between cycles. The _STALE_TURN_TIMEOUT (90s, in acp/client.py) is the real wedged-session guard — it fires when streaming has gone silent. CHAT_TURN_TIMEOUT is the upper safety ceiling for genuinely runaway work, not a "this turn took too long" guard.
Custom agent context: When a dashboard slot uses a non-kirocrew agent, build_message() and build_session_context() skip only skills and workspace identity (custom agents load their own via kiro-cli). All other context is injected for all agents: critical rules (diff rendering, OPTIONS buttons), memory (preferences, projects, history, semantic, episodic), lessons, hooks, and OPTIONS reminder. This ensures custom agents, cron jobs, and task runners all benefit from the user's learned preferences and project context.
Streaming chunks are cleaned up after each response (only final assistant message kept). Transient roles (chunk, done, queued, permission) are excluded from history saves.
File-change snapshots (chat_runner.py::_snapshot_write_target): captures before/after content for write-tool invocations, attached as file_changes meta on the last assistant message by _flush_file_changes. The 'before' content is sourced in priority order: (1) strReplace only — full-file reconstruction via _reconstruct_str_replace_before (declines outright for replaceAll edits, where oldStr uniqueness is not enforced and reversal would over-revert pre-existing newStr occurrences): read the file (regular files only — S_ISREG at the stat gate, so /dev/zero/FIFOs never read — ≤ _MAX_RECONSTRUCT_BYTES, re-checked post-read) through hooks.safe_read_file (symlink-safe: resolved-target re-check + O_NOFOLLOW) and reverse-apply the substitution from the tool params. Both _snapshot_write_target call sites in _run_chat are offloaded via asyncio.to_thread so a slow/hung filesystem cannot stall the event loop. Classification: newStr absent → post-write excluded, pre-write proven iff oldStr unique; newStr present but not unique (overlap-safe find()==rfind()) → post-write can neither be excluded nor reversed → decline; newStr unique → the single reversal candidate decides (tool-consistent AND pre-write plausible → undecidable seam, decline; consistent only → reverse; inconsistent + pre-write plausible → pre-write proven). This exists because kiro-cli's diff content block oldText for strReplace is only the replaced fragment — using it verbatim diffed a fragment-before against the full-file after and counted the entire file as additions (the #920 race fix's full-file assumption holds for create, not strReplace). Reconstruction declines (falls through) on missing params, empty-newStr deletions (position unrecoverable), unreadable files, or neither needle matching. (2) ACP diff content block oldText carried on the tool-call event (AcpEvent.diff_old_text, threaded from acp/_dispatch.py::_build_tool_call_event) — authoritative and race-free for create ('' for a new file, full previous content for an overwrite). (3) Disk read fallback — used only when no diff content block exists (e.g. the blocking session/request_permission path where the write has NOT yet executed). Both this fallback and the post-write 'after' read go through _safe_read_snapshot, which reads via hooks.safe_read_file_bytes_nolink (the descriptor gate described under Prompt and package-skill read safety below, within_root = the canonical path's own parent, byte cap 4 * _MAX_SNAPSHOT + 4 with truncation allowed so the truncation marker still appears exactly when the file exceeds the character cap, newlines normalized to \n as a text-mode read does so a CRLF 'after' diffs cleanly against the text-mode 'before') rather than validating the name and re-opening it — a hardlink alias of a protected file under an innocent name shares the target's inode, and only the opened descriptor's st_nlink reveals it. Every refusal is the same None an unreadable file produces. No-op entries (before == after) are kept and surfaced — the dashboard renders an explicit "no changes" caption for them (a backend drop would compare post-truncation/post-redaction content and silently discard real changes past the snapshot limit or inside redacted spans). Security invariants apply equally to content-block-sourced and reconstructed text: validate_file_path refuses sensitive paths before any source is consulted, _truncate_snapshot caps content length (after reconstruction, so the needle can't be cut mid-file), and credential/exfil-URL redaction in _flush_file_changes covers all sources. _truncate_snapshot also reports whether the cap was exceeded. When either side is incomplete, the persisted entry adds truncated: true and snapshot_limit_chars; complete entries retain the legacy {path, before, after} shape, so an absent flag remains equivalent to false for old transcripts. The dashboard treats a flagged entry as incomplete evidence: aggregate and row line counts are suppressed, inline and standalone diff actions are unavailable, and both expanded and minimal chip styles show the localized character-limit notice instead of no changes or a partial hunk.
Agent Config: PUT saves to ~/.kiro/agents/kirocrew.json and auto-restarts all kiro-cli sessions so changes take effect immediately.
Tool-Approval Resolution Persistence
A pending tool approval has two pieces of state that must stay in lockstep: the in-memory asyncio.Future in slot._approval_futures[request_id], which the chat runner awaits, and the permission message in slot.messages, whose cls JSON is what the UI renders the approval bar from. The message is the durable half — it is the only piece that survives a history reload.
Invariant: every path that resolves or discards an approval future MUST also mark the message. A future resolved without marking leaves a card that renders as pending while nothing is listening: the UI keeps showing live Allow once / Trust / Reject buttons, POST /api/chat/slots/{slot}/approve answers 404 no pending approval for all of them, and a reload resurrects the card. The resolution paths and their markers:
| Path | Marker |
|---|---|
HTTP POST /api/chat/slots/{slot}/approve | api_chat_slot_approve marks "trust" / "trust_reads" verbatim (the UI renders those distinctly), everything else as the coerced "approved" / "approved_trust_reads" / "rejected" |
| Slack approval click | DashboardState.resolve_approval marks "approved"/"rejected" |
| Bulk trust/yolo mode switch | api_chat_mode marks with the mode name ("trust" / "yolo") |
| Stop / interrupt | _reject_pending_approvals marks "rejected" |
Chat runner's own exits — 2h wait_for timeout, Slack delivery-failure auto-reject, task cancellation | the finally backstop in _run_chat marks with only_if_pending=True |
State-level approval wait expiry/cancellation (ApprovalCoordinator.request's finally, i.e. request_approval timing out or being cancelled) | retires before popping: the card is client-injected from the WS approval frame, and the approval_resolved broadcast marks it resolved. The broadcast uses the requesting slot as its session key whenever one is named, otherwise "state"; SEL records the explicit "expired" outcome, and the frame is the one approval_resolved payload that carries a decision ("expired"), which the SPA renders with its "stale" vocabulary instead of deriving a rejection from approved: false. A decided approval's frame stays {id, approved, slot?}. The coordinator owns no slot permission rows, so it deliberately does not call the marker: bare per-connection ids can collide with rows owned by the chat-runner registry. A future already decided by resolve()/resolve_state() emits no second retirement broadcast. |
| Turn-start sweep (repairs orphans from prior turns) | _sweep_stale_permissions marks "stale" |
resolved field values (cls.resolved): "approved", "approved_trust_reads", "rejected", "trust", "trust_reads", "yolo", "stale". Presence of the key — not its value — is what makes a permission message non-pending; selectSlotPendingApproval in the SPA and the only_if_pending guard both key off presence alone. The frontend additionally writes "stale" locally when a decision 404s, which clears the orphaned card in that tab; it is a display-layer dismissal, not a backend write. Persisted orphans converge via the next turn's sweep.
only_if_pending guard: _mark_permission_resolved(..., only_if_pending=True) returns False and writes nothing when resolved is already present. The runner's backstop uses it so it cannot flatten a richer decision a primary resolver already recorded — "trust" renders as "Trusted — auto-approving future calls" and would otherwise be downgraded to a bare "approved". It also suppresses a duplicate approval_resolved WS broadcast on the paths where the primary resolver already sent one.
slot._dirty obligation: _mark_permission_resolved mutates slot.messages in place and returns True when it wrote. The periodic flush (_flush_dirty_slots, every 5s) skips slots whose _dirty is False, so a caller that marks without flagging the slot can lose the write on restart and resurrect the card. Every call site holding the owning slot sets slot._dirty = True on a True return. This invariant is enforced by convention at each call site, not by an owning helper — a new future-resolution path must honor both halves.
Runner backstop totality: outcome is pre-seeded to "rejected" before the approval await, because the finally runs on every exit including CancelledError (slot deletion and cleanup endpoints cancel slot.task). Assigning it only inside try/except would raise UnboundLocalError from the finally, replacing the cancellation with a spurious exception and skipping both the message marking and the Slack prompt cleanup.
Dashboard send confirmation
An immediate dashboard send with ws=1 and meta.sendId emits its persisted
user row before the reply task starts, supplying a bubble skipped by a stale
busy snapshot. The canonical chat_message_frame uses the slot-authorized
broadcast_ws path; this user echo never enters the global /api/stream queues.
Both composers reconcile by sendId/server mid, demote an optimistic Steer
accepted as a new turn, and preserve render identity through meta.clientTs.
They retain sendId so a confirmed echo prevents a later HTTP timeout/reset
from restoring the delivered draft or showing an unconfirmed-send notice.
HTTP receipts still confirm delivery but never insert a skipped bubble.
Uncorrelated sends, actual queued/steered sends, and in-band/relay streams keep
their existing event paths.
Queue turn boundary finalize
A successor turn dispatched WITHOUT a chat_done -- the tail-drain starting a
queued turn (_start_next_queued_turn), and the synthesis dispatch in
_run_pending_synthesis -- broadcasts chat_segment for the slot once the
successor is certain to dispatch, before the successor's row and first chunk.
The end-of-turn flush suppresses its own chat_segment (deferring the
client-side streaming->assistant finalize to _finish_queue_cycle's
chat_done, which never comes on these paths), and the flush's
chat_message{role:assistant} frame is conditional (suppressed while an HTTP
SSE reader drains the slot, absent when the final segment is empty, droppable
by the client's mid-keyed redelivery guard) -- without the unconditional
boundary frame the successor's chunks append into the still-open streaming
row and a line-final [OPTIONS: ...] marker degrades to prose. Non-fire
condition: a boundary where no successor dispatches (empty queue, dropped
entry, synthesis not eligible) emits nothing here -- _finish_queue_cycle's
chat_done stays that path's sole finalizer, so no path double-finalizes.
Agent welcome message (welcomeMessage)
An agent spec may carry welcomeMessage, a hint its author wants read when that
agent starts answering. agent_discovery.agent_welcome_message is its ONE reader
and chat_runner._surface_agent_welcome its ONE emitter; before them the field
had no reader at all, which is why the bundled pptx_maker agents ship a value
nothing rendered.
Contract:
- Two activation points, dashboard only. A cold session start on a non-empty
slot.agent, and a provider-sideEVENT_AGENT_SWITCHED-- there the row lands immediately AFTERSwitched to agent: <name>. The channels are deliberately out of scope. - Row shape.
role="notice": outside_TRANSIENT_ROLES, so it persists and survives a reload; neitherusernorassistant, so the history replay never feeds it back to the model; drawn byNoticeCardas plain text, so config copy cannot render as instructions. - One shot per ACTIVATION, via
slot._welcomed_agent. Both call sites clear through that one field, since a switch and the cold start its own reset produces are two events for one activation. The claim is taken before the offloaded read, and released when the agent is empty so returning to an agent after the default greets again. Not persisted: the row is. - Which spec is live is
list_agents' answer, not this reader's. It takes the roster row matching the name and parses only that row's file, from the scope directory the row names. No precedence is restated here. - Untrusted input. Absent, empty, whitespace-only and non-string values
render nothing (
spec_str's rule); the text is capped atWELCOME_MESSAGE_MAX_CHARS(2000) including its ellipsis, and passes through_redact_display_text. The read is offloaded and total -- an unreadable spec renders nothing rather than failing the turn.
Rationale for each choice is in the three docstrings, not repeated here.
Mid-Turn Steer (dashboard transcript contract)
A steer (POST /api/chat with steer: true while the slot is running) injects
the message into the in-flight turn via kiro-cli _session/steer. kiro-cli does
NOT end the current text segment at the steer, so both sides of the boundary
must be handled explicitly or the transcript disagrees with what the user
watched stream (the "stuck streaming marker" bug: the live streaming message is
stranded ABOVE the steer bubble, the rest of the segment streams into it there,
and the finalized reply jumps BELOW the bubble when the chat_done refresh
rebuilds from server history).
Backend — segment cut at the steer boundary. _run_chat publishes a sync
closure on the slot (slot._steer_segment_cut, same lifecycle as
slot._acp_client: set at turn start, cleared in the turn's finally). The
steer handler calls it right BEFORE slot.append("user", …, meta={"steer": True, …})
(the meta also carries the client's sendId when the POST supplied one — see
the send-identity paragraph below),
so the accumulated segment text is flushed as its own assistant message and the
persisted order is [assistant(pre-steer), user(steer), assistant(post-steer)]
— identical to the live view. The cut persists SILENTLY: broadcast=False
(no chat_segment), quiet_persist=True (no per-message chat_message from
the append), and the wire redactor's withheld tail is dropped via
_wsred.reset() rather than emitted (no late chat_chunk). At the cut
boundary every client has already finalized its streaming message — the
initiating tab froze at optimistic-push time, other tabs freeze on the
steer_push echo — so any of those events would materialize a duplicate copy
of the pre-steer text (or a phantom streaming bubble) below the steer bubble.
No text is lost: assistant_text accumulates independently of the wire
buffer and is re-redacted at persist. The cut also sets
_produced_visible_output (the flushed segment is visible output; without it
a stream→steer→quiet-end turn would trip the empty-response requeue). The cut
is best-effort (a failure logs and never loses the steer) and also prevents the
chunk-entry leak: without it, _flush_segment's trailing-run walk stops at the
mid-run steer user message and the pre-steer chunk entries stay in
slot.messages for the life of the process.
Frontend — finalize-on-steer. Every path that INSERTS a steer bubble first
finalizes the live streaming message in place via finalizeTrailingStreaming
(streaming → assistant; placeholder-only content is dropped, same rule as the
_segment handlers, which share the helper): the optimistic push
(appendMessage, meta.steer), a pane-scoped optimistic push, and the
steer_push echo when no optimistic bubble exists to reconcile (another tab /
scene-interaction steered). The reconcile path deliberately does NOT freeze — by
echo time a new post-steer streaming message may be live below the bubble and
must keep streaming. Pinned by test_chat_steer.py (cut ordering, cut-failure
resilience) and the finalize-on-steer describe in chatSlice.test.ts.
Send identity — sendId through the steer path (#6075). The optimistic
steer bubble is minted with a client sendId (the same per-send convention the
plain send path uses) and the steer POST carries it as meta.sendId. Both
backend paths persist it: the accepted-steer row via
steer_into_running_turn — which normalizes the raw client value at entry
(normalize_send_id: the URL-safe id alphabet within SEND_ID_MAX_LEN, AND
nothing the canonical credential scanner would redact, since the value reaches
slot history and the steer_push broadcast without the outbound redaction the
message text goes through; anything failing either gate is treated as absent,
never truncated) and also echoes it on the steer_push
broadcast — and the raced new-turn row via the generic client-meta persistence
in api_chat. Resolution is id-first everywhere text used to be the key: the
steer_push reconcile matches the bubble by id (an id-mismatched bubble is
never consumed; a non-optimistic row already carrying the echo's id means the
chat_done refresh installed it first and the echo is a redelivery that
inserts nothing), and mergePreservedThinking resolves an optimistic STEER
bubble's accepted-vs-new-turn ambiguity from the covered page's row with that
id (steer flag = acceptance, scan continues; non-steer = new-turn boundary,
recorded so the finished turn's chip drops with coverage). Text identity never
resolves the bubble; an unmatched, duplicate, or absent id keeps the
decline-to-guess default. sendId is additive optional meta everywhere it
travels — no schema bump, and a send without one keeps the exact prior row,
payload, and scan behavior. Pinned by the sendId tests in
test_chat_steer.py, chatThinkingSteerBoundary.test.ts, and
ChatSliceCoverageSecondPass.test.tsx.
Send identity through the REQUEUE path (#6751). A steer whose turn dies
before kiro-cli confirms it does not persist its own row: the teardown degrades
it into a queue card and the DRAIN writes the row. That path is covered by the
same id, threaded one step further. steer_into_running_turn records the
normalized value in slot._steer_send_ids (keyed by the message text, like
slot._steer_delivery_ids, and removed in LOCKSTEP with it at every site --
the unwind, the terminal persisting tail, the hard-kill discard, and the requeue
itself -- so an entry in one always implies an entry in the other),
_requeue_unconsumed_steers moves it onto the queue entry's meta beside
steer_delivery_id, and the drain's union over every consumed entry's meta
carries it onto the row it appends. The three STEER_REQUEUED returns are
deliberately NOT the write site: two of them cannot be, because one returns
before the teardown has requeued anything and the other after the drain has
already written the row, so the requeue is the only writer common to all three.
The resulting row is a non-steer user row carrying meta.sendId -- the same
shape api_chat's new-turn path already persists -- so mergePreservedThinking
reads it as a new-turn boundary for that id and the finished turn's chip drops
with coverage instead of stranding at the tail until a reload. No client change.
Known residual: _drained_meta accumulates only steer_delivery_id (into
steer_delivery_ids) and is last-writer-wins for every other key, so a row that
MERGES two requeued steers keeps one sendId and the other send keeps the
over-keep default. Pinned by the requeue sendId tests in
test_steer_requeue.py.
Wait countdown and early end (/api/session-keepalive as a control channel)
While the MCP wait tool sleeps, the transcript shows a live countdown and an
"End wait" button that returns the tool early without ending the turn. Both
ride POST /api/session-keepalive, which is therefore no longer write-only.
Why that endpoint and not a cancel path. The tool sleeps in a separate MCP
subprocess that runs no inbound listener — communication is outbound
urllib only. The one channel that can interrupt it, notifications/cancelled
on stdin, is unusable for this: it is a session-teardown signal whose response
_run_tool suppresses entirely (so kiro-cli would wait out the 600s stall
watchdog), it requires mcp_gateway.enabled (default False), it cancels
every in-flight request for the PID's stubs, and on Windows there is no path
at all because select.select() cannot poll stdin. The keepalive round-trip was
already running on a timer, already authenticated (X-Internal-Secret), and
already carried session identity (X-Session-Key) — and its reply was being
discarded.
Contract:
- Request (
waitonly; every other caller still sends{}and is unaffected):{wait_id, seconds, remaining, interval}, or{wait_id, wait_done: true}on the final ping.wait_idis a uuid4 hex minted per sleep. The body is optional and advisory — a malformed or non-object body degrades to touch-only rather than failing the keepalive, because that half of the route is what stops the watchdog killing the ACP subprocess mid-wait. - Reply:
{"ok": true}, plus{"end_wait": "<wait_id>"}when the user has asked to end that sleep. The tool breaks its loop and returns a normal string — deliberately notToolCancelled, whose response is suppressed. - Ping interval is
mcp_core.WAIT_PING_SECS(5.0s), not the 60s the staleness watchdog alone needs: it is the button's worst-case latency. The handler only touches two timestamps, so a 30-minute wait costs ~360 loopback POSTs and no meaningful work. POST /api/chat/slots/{slot}/end-waitwith{wait_id}parks the request on the slot for the tool to collect. 400wait_id_required, 404slot_not_found, 409wait_not_in_flightwhen the id does not match the sleep currently tracked — which is how a click on a stale countdown is rejected instead of ending a later wait. Consumed exactly once._ChatSlotgains_wait_state({wait_id, seconds, deadline_ts}, emitted aswait_stateinto_dict()), plus the server-only_wait_last_pingand_wait_contested.deadline_tsis absolute and minted once on first sight from the tool's ownremaining: recomputing per ping would jitter the countdown by a round-trip, and the tool's monotonic clock shares no epoch. Riding the slots payload (rather than a bespoke WS event) is what makes a mid-wait reload re-seed the countdown fromGET /api/chat/slots. Pushed on start and end only — never on the 5s heartbeats.
Authoritative identity is a precondition, not a nicety. Before publishing
anything the tool calls _resolve_session_key_strict(), and when that comes back
empty it sends the original bare {} ping and refuses to honour end_wait at
all. The countdown and the button simply do not appear. This is the primary
control, and it is what makes the channel safe rather than merely
usually-right: _resolve_session_key() — the lenient resolver behind the
X-Session-Key header — ends its ladder with a /proc ancestor walk, so a
subagent's MCP-core child walks up into its parent slot's process tree and
resolves to the parent. Publishing a wait_id under that key puts a
subagent's deadline on the parent's pill and lets the parent's button end the
subagent's sleep. Nothing downstream can catch that case, because with a single
wait_id pinging there is no collision to observe. _resolve_session_key_strict()
is the existing primitive for this class of session-mutating tool
(monitor_start, autonudge_stop, set_project): it drops the walk and accepts
only a gateway-injected caller context, KIROCREW_SESSION_KEY, or an
HMAC-verified pid sidecar. Consequence worth stating plainly: with
mcp_gateway.enabled at its default of false and no sandbox launcher, the
feature is absent. #2347 is
the work that supplies per-session identity and lets this gate go away.
Ambiguous-identity containment. Behind that gate, two sleeps can still share
one slot wherever identity resolves to a session that hosts both. The ping doubles
as a heartbeat, which makes that detectable — a second wait_id arriving while the
incumbent is still pinging (within interval * 2.5) means two sleeps genuinely
share the slot, so neither is tracked, any parked request is dropped, and the
slot is marked contested. The countdown disappears and the feature degrades to the
bare spinner it replaced. The latch is turn-scoped — released only by the
turn-end block in chat_runner, never on a timer. A self-expiring window was
tried first and rejected: both sleeps keep pinging, so on expiry whichever pinged
first re-minted its own wait_state, and for up to one ping interval that
deadline was published and painted onto the other sleep's pill with a live
button that would have ended the wrong one. The attribution flapped open once per
window instead of staying shut. A wait_id change after the incumbent
goes quiet is still a legitimate hand-over (missed wait_done, killed process).
Two frontend guards cover the one-sided case: the countdown requires the slot to
be running and requires its pill to be the transcript's newest tool row of any
kind — within one session the agent is blocked on the wait's result and cannot
issue another call, so a later tool row means the wait is over or belongs to
someone else. All three are containment, not a cure; the cure is per-session
identity and is tracked separately in
#2347, which lists what to
delete here once it lands.
Frontend joins the countdown to a pill by tool title, via isWaitToolTitle(),
an allowlist of the shapes the transport produces: wait, <server>___wait,
wait (mcp), and the two decorations combined. Deliberately stricter than its
backend counterpart acp/liveness.py::is_wait_tool, and the asymmetry is
intentional rather than drift. The backend's looser rule only answers "is this
session legitimately blocked in a long tool", where over-matching merely declines
to reap something; here a false positive misattributes a live deadline. Concretely,
a per-token rule accepts an unrelated wait_for_ci from another server, so a
subagent's wait could publish its deadline onto the parent's wait_for_ci pill
and arm the button against the wrong sleep — and the contested latch cannot cover
that case, because only one wait_id ever pings and there is no collision to
detect.
Dashboard-only: the Slack renderer tears its stream down for the
duration of a wait, so there is no surface there to host either affordance.
Agent Questions (ask_question)
ask_question is non-blocking as of issue #755: the tool renders a question card in the caller's own chat window and the agent ENDS its turn — it no longer holds the turn open on a server-side wait resolving to an answer map. User-facing documentation lives in src/kiro_crew/docs/agent-questions.md; this section is the contract.
The MCP tool resolves NO session identity of its own and calls NO HTTP endpoint. It validates its arguments (ASK_QUESTION_SCHEMA) and returns a session directive — a human-readable confirmation line plus a machine-readable marker (session_directive.encode) carrying the validated questions and no session key (the stateless mechanism is specified in session.md → "Stateless session-directive tools", src/kiro_crew/session_directive.py). The session-aware consumer — chat_runner._run_chat's EVENT_TOOL_RESULT handler, which owns the turn's authoritative slot/session_key — decodes the marker and applies the effect in-process against ITS OWN slot via dashboard/session_directive_apply.py::_ask_question.
Application posts the card through DashboardState.post_question_card(slot_key, questions): it runs _redact_questions over every question/header/option string, broadcasts a question_card with no ask_id to the slot's owner sockets, and returns the count of clients that received it (zero → the model is told to ask in plain text and end its turn). There is no future, no _pending_questions/_question_futures entry, and no question_card_resolved follow-up — nothing is left pending, so no stop/interrupt/delete or session-reset path has a blocking wait to unwind for questions.
The user's answer arrives as an ordinary next message that resumes the session with full context; the frontend submit for an ask_id-less card uses the same send-as-a-normal-message path the legacy AskUserQuestion tool-call sniff already used (chatSlice.pendingQuestions remains keyed by slot). A live user row retires the stateless card on both the server and client — and only a user row: an auto-nudge cycle wakes the same agent in the same conversation, so it does not consume the answer channel, and retiring on it destroyed the only affordance for a question that was still open (both the card and the /api/ask-question/pending record a reload rehydrates from). An unanswered stateless card otherwise persists until it is answered or dismissed. Mid-turn steering has a second authoritative retirement point: the dashboard persists the user row when the steer RPC accepts it, but kiro-cli confirms that the running turn actually consumed it later via steering_consumed. If the agent posts a question card between those events, the earlier row cannot retire a record that did not exist yet; a positively matched steering_consumed event therefore retires the slot's stateless card and broadcasts question_card_resolved. Empty or unmatched steer echoes prove no answer was consumed and retire nothing; legacy blocking ask_id cards remain owned exclusively by their parked round-trip.
Blocking HTTP API
The MCP ask_question tool does not call this API: it returns a stateless, non-blocking session directive. POST /api/ask-question remains a separate blocking round trip for owner callers and returns only after the card is answered, dismissed, cancelled, or timed out.
All four endpoints call _deny_app_token and _deny_non_owner before reading their bodies. App tokens receive 403 {"error": "app token not permitted for this endpoint", "code": "app_token_forbidden"}; non-owners receive a 403 owner-only denial.
POST /api/ask-question
Request body: {session_key, questions: [...], timeout_secs?}. session_key must resolve to an existing slot; questions uses the same validator as the tool; timeout_secs, when supplied, must be an integer and is bounded by the blocking wait.
Success responses are 200 {"status": "answered", "ask_id", "answers"} or 200 {"status": "timeout", "ask_id"}. Invalid JSON, a non-object body, a missing session_key, invalid questions, a non-integer timeout, or duplicate keys after redaction return 400; an unknown or unrenderable slot returns 404.
GET /api/ask-question/pending
Returns 200 with an array of cards that can be rehydrated after a reload or websocket reconnect. A blocking card has {ask_id, slot, questions, ts}; a stateless card has {card_id, slot, questions, ts}. Empty or status-only records are omitted.
ask_id identifies a parked blocking wait and is answered through the endpoint below. A stateless card_id has no blocked caller: its answer is the next ordinary user message, and its status is retired through the dismiss endpoint or that message.
POST /api/ask-question/dismiss
Request body: {slot, card_id}. This endpoint retires only a stateless card's needs_input record and returns 200 {"ok": true}.
Invalid JSON, a non-object body, or missing slot or card_id returns 400; an unknown, stale, or blocking card record returns 404. It cannot dismiss a blocking ask_id card.
POST /api/ask-question/{ask_id}/answer
Request body: {answers: {question: answer}}, or {dismissed: true} to resolve the blocking wait without an answer. Successful resolution returns 200 {"ok": true}.
Invalid JSON, a non-object body, missing or empty answers, more than four answers, or overlong question keys or answer values return 400; an already answered, expired, or unknown ask_id returns 404.
Blocking lifecycle
A blocking card is registered under its ask_id until its wait exits. Answering, dismissing, timing out, or cancellation retires that record and broadcasts its resolution.
Stopping, interrupting, or deleting a slot unblocks its pending blocking questions. Session reset uses the same unblock path, so a blocking wait cannot outlive the session that issued it.
The agent must end its turn and must not re-ask or guess in the meantime.
Forgery gate + audit (shared by every directive tool): the consumer honours the directive ONLY when the tool call was recorded — via kiro-cli's out-of-band _meta channel — as an MCP call whose canonical _meta.kiro.toolName (with _meta.kiro.mcpServerName set) is a known directive tool, never the LLM-authored title; native sub-agent tool calls are refused (no independently bindable slot); and every application emits a SEL tool-invocation event tagged source="mcp-directive".
Non-dashboard surfaces: at validation time the tool still inspects its session shape — a resolved non-dashboard: session key is answered with the [OPTIONS:]-tag hint instead of a directive (a card needs a chat window), while an empty default-install key falls through to the directive, which the consumer applies (or reports as undeliverable when no client is attached).
Bounds (validation.py, single source of truth) still gate the tool's arguments at validation time: ≤ _ASK_MAX_QUESTIONS (4) questions, ≤ _ASK_MAX_OPTIONS (6) options each, question 500 / header 50 / label 200 / description 500 chars. The answer no longer returns through a bounded answer map — it arrives as an ordinary chat message (above) — so it is subject to normal message handling rather than the former _ASK_MAX_ANSWER_LEN truncation.
Legacy blocking stack (retained in code, no longer used by the tool, slated for removal). The former second blocking round-trip — DashboardState.request_question / resolve_question over _pending_questions / _question_futures, the ask_id, the question_card_resolved broadcast, the POST /api/ask-question + POST /api/ask-question/{ask_id}/answer + GET /api/ask-question/pending endpoints, the caller-chosen _QUESTION_TIMEOUT_* window bounded by the ACP tool-stall watchdog (acp/client.py::_TOOL_STALL_TIMEOUT), and the _unblock_pending_waits / cancel_questions_for_slot release chokepoints — still exists in the codebase but is dead on the ask_question path. It is described here only so a reader tracing those symbols knows they are no longer how the tool works.
Tasks & capacity (handlers/tasks.py, pages/system/TasksCapacityCard.tsx)
The durable task queue's read surface and the one dashboard panel that renders it (System > Services, below the host-runtime card). It exists so an operator with 2000 accepted tasks can see the queue drain, the concurrency it drains under, and why that concurrency is below what they configured — without reading logs (SPEC §二.8, RFC overload-resilience §7).
Routes (all behind the ordinary token middleware, NOT on the internal-path
lists, so an MCP caller with only the loopback secret is refused like
/api/sessions/health):
GET /api/tasks?state=&lane=&limit=— rows fromTaskStore.list_rows, oldest first,limitin[1, 1000](default 100; over-cap values are clamped, non-integers are 400bad_limit; an unknownstateis 400bad_statenaming the valid set). Each row carriesid, kind, state, lane, session_key, parent_id, root_id, attempts, generation, next_run_at, deadline_at, lease_owner, lease_expires_at, wait{reason, since, deadline_at, resume_kind, dependency_scope, cancel_semantics, tool_call_id}, wait_reason, wait_since, wait_deadline_at, age_secs, created_at, updated_at, terminal.laneis the stored column (taskq.lanes, resolved at accept so a nested row reports its ROOT's lane), so?lane=is the store's ownlist_rows(lane=)SQL predicate.age_secsis measured on the STORE's clock (TaskStore.now), nevertime.time(), so it agrees with the rows' own stamps under an injected clock.GET /api/tasks/summary—{generated_at, available, depth{by_state, queued, waiting, recovering, running, total}, oldest_wait_secs, lanes{<lane>: {effective, user_max, running?, window_queued?}}, degrade_reason, adaptive, slots[{key, classification, age_secs, evidence}], waiting[<row>], recovering{tasks[<row>], task_attempts, slots[…], ladder[…]}, stalled, counts, stall_after_secs}.depth.queuedsumssession_health.TASK_QUEUED_STATES(queued + admitted + retry_wait + waiting_infra) — the constant itself, shared with/api/sessions/health'squeued.count, never a second list here.depthis a set of VIEWS over the rows, not a partition of them:retry_waitcounts under bothqueued(it waits for capacity) andrecovering(it is being retried). Everydepthnumber is counted off the one uncappedcount_by_state(),depth.recovering(recovering + retry_wait) included, so no two numbers in one response can disagree about how many rows there are. The ROW LISTS are pages:waitingcomes from the uncappedactive_rows(), butretry_waitholds no runtime and so is read by alist_rowsbounded at_LANE_SCAN_LIMIT(5000) — and countingdepth.recoveringoff THAT would make a page size a silent cap on a count, which the card cannot survive, because it computes "waiting for a slot" asdepth.queuedless the uncappedby_state.retry_wait: every row past the page would then appear in neither number. Pinned bytest_api_tasks.py::test_every_depth_number_counts_the_same_rows_as_by_state, which shrinks the page rather than the volume.lanesmerges the health monitor's registered cap sources (the subagent manager's live cap undersubagents) withresource_status.adaptive_state()— the controller suppliesuser_max(exec_ceiling) and thespawn_gatelane; a value the monitor already published is not overwritten.degrade_reasonis the monitor's registered pressure source when one answers, else the controller's own reading (paused → its last decision's reason, probing, or adecrease/pauselast action).recovering.task_attemptssumsattemptsover therecovering/retry_waitrows THAT PAGE CARRIES, so it is an attempts figure for the rows on the wire and never a second answer to "how many rows";recovering.ladderisdefault_ladder().table().availableis false when the gateway has noTaskStore(a MagicMock state, the legacy in-memory queue) — the panel then says so instead of rendering zeros. The slot snapshot is taken ON the loop, everything else into_thread; the log scan is never run here (/api/sessions/healthowns that evidence).GET /api/tasks/{task_id}—{task: <row>, events: [{seq, ts, kind, data}]}, the last_EVENTS_TAIL(200) events oldest-first. 404not_found; 404unavailablewith no store; 503unavailableon a store read error.
Store prose is redacted on the way out, on every route above. A row's wait
reason and an event's data carry a failing tool's, subagent's or dependency
adapter's own text — a finish(error=…) detail, a GitHub stderr tail, an HTTP
body — which the store keeps VERBATIM so the row stays diagnosable after the
process that failed is gone. handlers/tasks._scrub_prose runs
security.redact_and_truncate over every string leaf, walking nested dicts and
lists (a credential inside a signal dict is the same leak), which is the
composition subagent.py's _redact and taskrunner._execute_tasks spell for
the same strings: exfiltration URLs first, then credentials. That order is
load-bearing — a URL whose query IS the credential is what the URL rewriter
exists for and it removes the whole URL, while credentials-first leaves
?token=[REDACTED: credential], in which nothing suspicious remains, so the
destination survives. Non-string leaves and mapping keys pass through (a number
carries no credential; a key is a field name the writers spell in code).
Identifiers — id, lane, session_key, lease_owner, an event kind — are
served as stored: the panel keys its rows by them, and rewriting one would break
the match. The list and detail routes build their payloads INSIDE the
asyncio.to_thread that reads the rows, because a redacted limit=1000 page or
a 200-event tail costs about as much as the read itself and the loop is shared.
Pinned by test_api_tasks.py::test_task_routes_redact_store_prose
(a real store, a real handler, both shapes, nested) and
::test_redaction_runs_exfil_before_credentials.
-
POST /api/tasks/{task_id}/cancel— cancels THROUGH the owner that can stop the work, never by writing the store for a running row. Subagent rows:SubagentManager.cancel(task_id)— the manager's cascade is what unqueues a row that never started (_unqueue→taskq_cancel_queued), reaps a live one (_force_reap→taskq_settle, children cancelled children-first), and writes the SEL record, so a task stopped here is indistinguishable from one stopped on its activity card. TaskRunner step / workflow agent-call rows (taskq.adapters.runner.owner_of): a parked row (queued, admitted, deferred, any wait —taskq.model.PARKED) ends throughRunnerAdmission.cancel_wait(the parked coroutine resumes and sees the cancel); a row whose runtime is live (starting,running—taskq.model.EXECUTING) cancels its run (TaskRunner.cancel(run_id, exact=True)/WorkflowService.cancel(run_id)) because a step is one unit of its run and the run's own exit path settles the row. The row read here ROUTES the cancel but does not authorize it: the parked branch hands that read's generation tocancel_wait, so a row an admission takes live in between is refused by the store rather than cancelled over a live step, and the caller sees the fresh state. Answers{ok, cancelled, task}; 409terminalfor a row already ended (no owner is called); 409no_cancel_adapterwhen no owner took the cancel and the row is still live — including that refusal, where the row the reply carries is the live one it moved to and the live lever is one more click; 404not_found; 500cancel_failedwhen the cascade raised.test_api_tasks.py::test_cancel_goes_through_the_manager_not_the_storepins the no-direct-write property for subagent rows by tripping every store writer;test_overload_integration_glue.pypins the runner routing. -
POST /api/tasks/{task_id}— one action on a TaskRunner / workflow row:{"action": "answer_input", "answer": "<text>"}delivers the operator's answer to awaiting_inputrow throughRunnerAdmission.answer_input(bound to that row's wait; the runner never auto-answers, and a late answer after the wait ended is 409not_waiting_input);{"action": "cancel_wait"}ends the wait of a row that holds NO live runtime, without an answer (rowcancelled). This route is wait-only: it never reaches the owning run, so a row whose runtime is live (starting,running) is 409not_waitingand the store is not written — cancelling the wait under a live executor would leave it running against acancelledrow, the mirror of the orphan row the queue exists to remove, and the next reconcile would then settle or re-dispatch work that is still executing. Stopping a live row is/cancel, which routes through the owner.The state tests here are pre-checks; the fence is the writer's. Both arms read the row over the wire, and a row can move between that read and the write, so the verdict that decides is the store's under the generation the read returned AND over the one state the action answers —
cancel(only_from=taskq.model.PARKED, generation=…)forcancel_wait,wake_wait(only_from={waiting_input}, generation=…)foranswer_input(taskq.md§ A cancel whose PRECONDITION came from an earlier read). An answer answers ONE question, so the wake predicate iswaiting_inputalone and not the wholeWAITINGset: a row that has finished that question and parked on a dependency, or on its children, is in a DIFFERENT wait, and ending that one with an answer to a question already over would report 200 and leave the row claimable with its dependency still unmet. A refusal there answers the SAME code the pre-check would have, read off the row's state as it stands: 409terminalfor a row that ended, 409not_waitingfor one an admission took live, 409not_waiting_inputfor an answer whose wait is over or whose row is in another wait. 409action_failedis what remains for a refusal the state does not explain — the store was unavailable, or the row moved on under a new generation while still parked — because naming a state reason there would be a guess. A cancel that did not happen is never 200. Both arms test TERMINAL FIRST, on both paths, because a settled row is not "an answer whose wait is over" and only that ordering makes the two paths agree; pinned per action bytest_overload_integration_glue.py::test_a_settled_row_reads_the_same_code_whichever_side_of_the_window_it_landed.400
invalid_json/bad_action(names the valid set) /answer_required; 409no_input_adapterfor a subagent row or when no admission is attached, 409terminal, 409not_waiting, 409not_waiting_input, 409action_failed; 404not_found/unavailable. Registered before/{task_id}/cancel.test_overload_integration_glue.py::test_cancel_wait_refuses_a_row_whose_runtime_is_livepins the no-direct-write property the same way the/cancelsibling's is pinned: every store writer trips if the handler reaches it for a live row.::test_cancel_wait_refuses_a_row_a_concurrent_admission_took_livepins the race itself — the handler's read held open while a realadmittakes the rowrunning— and::test_every_state_gets_the_route_verdict_its_own_set_namespins that every PARKED state still cancels through the real route. The answer arm's own race istest_api_tasks.py::test_answer_input_is_refused_when_the_row_moved_to_another_wait: the row re-parks on a dependency under the handler's read, through the store's own writes, and the route answers 409 with the dependency wait intact.The release reaches the parked coroutine, and the route's thread is not the loop's. Both arms run in
asyncio.to_thread, soRunnerAdmission.on_wake— the only thing that sets theasyncio.Eventa parked run awaits — is entered on a worker with no running loop.Event.setis a loop operation (it resolves the waiter's future, which schedules that future's callbacks), so the set is posted to the loop the waiter parked on withcall_soon_threadsafe, the shapeconfig.live.LiveConfig.notify_writtenuses for the same reason. Making it in place would be the cross-thread call asyncio refuses underloop.set_debug, and the operator's stop would leave the run waiting for a wake already spent; pinned bytest_taskq_runner_adapter.py::test_an_operator_action_from_a_worker_thread_releases_the_parked_run.
Panel (TasksCapacityCard): polls api.tasksSummary() every 5s (the
spawn-panel cadence) and renders three columns in the Services plane's own
label/value shape — Queue ("Waiting for a slot" = depth.queued LESS the
retry_wait rows by_state reports, running, "Paused mid-run", "Tasks
retrying" = depth.recovering (task rows re-dispatched after a stall plus rows
parked in retry_wait), oldest wait), Capacity per lane (the cap in force
leads every lane — the bare "Up to N at once" while the lane is not under the
user's ceiling, and one line carrying both numbers when it is — then the cause
where this lane is its home, then the live count ("N running now") — never
N of M, which beside the running line reads as two answers to one question),
Recovery ("Retry attempts" = recovering.task_attempts, "Backends restarting" =
recovering.slots (live chat/run sessions restarting after a stalled turn —
sessions, not rows), stalled) — then one list, stalls first and longest wait
first within each group, of every waiting/retrying task row, every stalled slot
and every other non-running live slot with its state badge, id, reason, attempts
(when >1) and age, bounded at 8 rows by a fold that OPENS IN PLACE (a "Show N
more" / "Show fewer" button carrying aria-expanded and aria-controls on the
list): a count of rows the reader cannot reach is a dead end, and the bounded
default is what keeps a 2000-row queue from becoming the whole card.
The capacity column states a relationship, never two numbers. Two maxima
printed as two labelled facts are read as two unrelated answers, and this column
answers one question. So effective == user_max is ONE fact ("Your full limit",
no second number to reconcile) and effective < user_max puts BOTH numbers in ONE
line, joined by the verb that relates them ("Up to 4 at once, lowered from
your 14"), plus the degrade reason itself when that reason is one of the mapped
adaptive_* tokens. A bare cap stacked over a line that calls the ceiling "your
limit" states two limits, and a reader asks which of them is in force; one line
makes the second number the ORIGIN of the first, and the cause beneath it
continues the same verb. cap_ceiling ("Your limit: N") is left for the wire
contradiction alone, a cap ABOVE the configured ceiling, where neither of those
statements is true. The ceiling is glued to the word before it with U+00A0 in
every locale that can break there: a wrapped line leaving a bare "14" under
"lowered from your" puts back the loose second number this line exists to remove.
The bare "Up to N at once" is the ONE line this column keeps from wrapping, since
it is short; every line that says more than the number is prose, set in the UI
font and free to wrap, because a phrase that cannot wrap at the ~230px a column
gets lands on top of the column beside it. The lead line is never muted whichever
shape it takes — it is the answer the column exists for.
The reason has ONE home, the closest to the number it explains: the lane line
when EXACTLY ONE lane is under its ceiling and this catalog can say why, else the
"Degrade reason" row. degrade_reason is one GLOBAL string and the controller
lowers global concurrency, so two lowered lanes are the ordinary case under
adaptive_decrease / adaptive_pause, and a cause printed per lowered lane is
then the same sentence twice — which reads as two causes. Above one lowered lane
no single number owns the cause, so the row states it once for the whole card.
That row is therefore absent when nothing is degraded — a labelled "none" beside
a red "Stalled" badge reads as a missing answer rather than as "nothing lowered
this", and the stalled COUNT in the Recovery column, with its row in the list, is
where a stall is reported — and absent again when the single lowered lane already
carries the sentence.
Both arms are pinned: one lowered lane puts the sentence on that lane and drops
the row, two lowered lanes put it in the row exactly once and on neither lane.
Machine text never reaches a lane line: an unmapped reason stays in the row, in
mono with translate="no", while mapped catalog copy there is set in the UI font
like the prose it is. A payload carrying no lane renders
the two the endpoint publishes (subagents, spawn_gate) with an em dash, since
a label reachable only in the no-data state ("Effective cap") teaches a term the
populated card never uses again.
Every counter label is DISJOINT from every other one, and so is the SET each one counts, because the reader has no tooltip open while comparing them: "Waiting for a slot" is accepted work holding no runtime and not being retried, "Paused mid-run" is a run that already started and gave its slot back, "Tasks retrying" is queue rows, "Retry attempts" is the attempts those rows have spent, "Backends restarting" is sessions, "Stalled" is slots making no progress with no wait reason that explains it. Three of those numbers are computed BY THE CARD rather than read off the wire, because the wire's own fields double-count or undercount against that rule:
depth.queuedsumsqueued + admitted + retry_wait + waiting_infra, anddepth.recoveringcounts those sameretry_waitrows as work being retried, so the card SUBTRACTSby_state.retry_waitfrom "Waiting for a slot". A row parked until its next attempt is retrying — the more specific fact, and the one its own list badge ("Waiting to retry") states — so it is counted under "Tasks retrying" alone.queued_tipstates that rule; a tip that confessed the overlap instead would leave the two numbers unaddable on the surface where they sit side by side.- "Paused mid-run" is counted off the list's OWN entries (the
waiting_*ones), not offdepth.waiting: the wire field counts store rows only, so a live chat slot parked on the operator's approval was a list row that no counter admitted to. - "Stalled" is counted off those same entries, so the count and the row it points at come from one array.
The card therefore reconciles — list rows == Paused mid-run + Tasks retrying +
Backends restarting + Stalled — for as long as the summary's retry_wait page
holds every such row, which is the only way any of these counters and the rows
beneath them can differ: "Tasks retrying" is depth.recovering, counted off the
uncapped by_state, while the list carries at most _LANE_SCAN_LIMIT of those
rows. Past the page the COUNT stays the truthful one and the list is short of it,
which is the right way round — the alternative caps a count, and then a queue
larger than the page reports rows in neither "Waiting for a slot" nor "Tasks
retrying".
One concept, ONE word: nested work is subagents everywhere the card names it
— the lane ("Subagent runs"), the waiting_children badge ("Waiting for
subagents") and the "Paused mid-run" tooltip. "Task" is already spoken for here
(a queue ROW: "Tasks retrying", "Retry attempts"), so "child task" or "subtask"
would spend one word on two things, and the key stays state_waiting_children
because a key names its WIRE state. Each locale uses its own subagent term
(子代理 in zh-CN, per that catalog's style glossary), never a calque of
"children". The rule reaches the backend's wait_reason prose too ("waiting on N
subagent(s)", taskq/waits.py): that string is evidence text the card passes
through untranslated rather than card copy, so it is not catalog-checked, but it
renders in the same row as the badge and a second word there would read as the
drift this rule removes.
The same rule governs RETRY, where three surfaces can spell it — a queue counter,
a column heading and the list heading — and one word across all three belongs to
none of them, which is why a reader cannot otherwise tell whether "Retry
attempts" and "Tasks retrying" count the same jobs. They do: the same rows,
one counted as rows and the other as the attempts those rows have spent, and each
tip names the other's unit so the pair is answerable without leaving the card. So
the counters are "Tasks retrying" and "Retry attempts", the list is "Waiting &
retrying" — the words its own badges use ("Waiting to retry", "Retrying now") —
and recover* survives only as the column heading "Recovery", the grouping.
The four retry surfaces name four disjoint things in that ONE vocabulary: "Tasks
retrying" is the rows, "Retry attempts" the attempts those rows have spent, the
two badges ("Retrying now", "Waiting to retry") the two states inside that count,
and "Waiting for a slot" the accepted work that is NOT among them — which is why
the retry-wait subtraction above is what makes the vocabulary answerable rather
than merely consistent. The keys stay recovering, task_retries and
section_waits because a key names its wire state, not its copy. Both halves of
this rule are pinned by TasksCapacityCard.test.tsx.
A row whose wait is on a PERSON (waiting_input / waiting_permission) and
whose session is a dashboard chat carries one trailing labelled "Open chat" link
to /chat?sid=<slot> (sessionRows.sessionChatPath, the Sessions plane's own
route). The id is never the control, on any row: colour alone cannot distinguish
an actionable id from the identically shaped inert ones beside it, and the
visible label IS the link's accessible name (no aria-label, so WCAG 2.5.3
holds in the locales that reorder words). Every other state, and any session
with no chat window (cron, channel, _bg), renders no link. The card stays
READ-ONLY — it never calls POST /api/tasks/{task_id}; the answer/approve
lever lives in the chat the link opens.
A stall always has a row. It is the one condition on this card that nothing
recovers on its own, so the number that raises the alarm must be openable. The
card reads ONE set — the stalled map's keys unioned with every slot whose own
classification says stalled (stalledKeysOf) — and the badge, the "Stalled"
counter and the list rows all come off it, so the card can neither go red over a
stall with no row nor list a row no counter admits to; the key is skipped in the
slot loop, so one stall is one row. That row's reason column carries the stall's
EVIDENCE rather than its reason field, because session_health files
no_progress there for every stall the structured view classifies — the badge
already says that — and puts a sentence there only from the log-scan arm, which
this endpoint never runs; the reason is therefore the fallback for a stall with
no evidence. A stall renders no "Open chat" link (nobody is being waited on, and
the card is read-only) and sorts AHEAD of the waits: the fold shows the first rows,
so ordering by age alone can hide the one row the red badge names behind it, and a
five-second stall is still the alarm where a two-hour approval wait is not.
A healthy / degraded badge sits in the title, and the late-queue one is "Backlog":
"Backed up" is first read as a backup copy. Its rules read the numbers the card
SHOWS, never a wire field the card presents differently — the depth arm counts the
slot waiters the Queue column prints, not the raw depth.queued, because an amber
"Backlog" over a queue count below its own threshold is a verdict the card
contradicts on the same line. No badge sits there at all when no
verdict is supported. The stalled and degraded rules read the live session
monitor and the controller, which a gateway with no task store still publishes, so
those two verdicts stand on any install; every input the healthy and backlog rules
stand on (depth, oldest_wait_secs) is a zero such a payload has nothing to fill
with, and a green "Healthy" over the store notice is then a health claim about a
queue nothing is recording — indistinguishable, to the reader, from "there is
nothing to show". So cardHealth answers null there and the notice is the only
statement. Empty state is one line ("Nothing
is waiting or retrying."); a gateway without a store gets a notice above the
columns that names what the READER loses ("Queue history isn't stored here; only
live sessions are shown."), never the component that is absent — "gateway" and
"task store" are this codebase's words, not theirs. A failed fetch renders
through ErrorNotice with the agent hand-off on, and its error.message is
printed only when that message is PROSE (proseReason: two or more
letter-bearing words, and no leading {, [ or <). An empty JSON error body
reaches the client as {} and a body-less refusal as HTTP 500; either one,
printed after "Could not load the task queue", is a blank where the cause
belongs, so the sentence stands alone while the raw text still reaches the
hand-off through the error journal (findReport). Wait names, lane names and the
degrade reason resolve through full-literal
catalog-key maps (pages.tasksCapacityCard.state_*, lane_*, degrade_*) so
the i18n key-refs gate sees every label: the three adaptive_* tokens
slack/gateway.py publishes become catalog copy, because the badge already
reads "Degraded" in the reader's language and the explanation beside it cannot
be a snake_case wire token. Any other reason — the controller's own free-text
reading — renders verbatim with translate="no" because that text is machine
data; an unknown wait or lane token likewise renders as itself. Tests:
test/test_api_tasks.py (26; tmp store, fake manager, real token middleware
for the auth case, route order), website/src/test/TasksCapacityCard.test.tsx.
UX evidence: website/scripts/capture-tasks-capacity.mjs photographs the real
built card, one scene per branch it colours or explains — including BOTH homes of
the degrade reason (the row when two lanes are lowered, the lane line when one is)
and both arms that mount no health badge — and refuses to write a frame whose
title row or badge is clipped or painted over.
Key Endpoints
AutoNudge maintenance: maintenance_service() gives administrative recovery one authoritative store view and holds a per-data-home transaction lock across its full cleanup; service startup and public add() / update() / remove() transactions take the same lock, so maintenance cannot scan a temporary in-memory absence from a removal that later rolls back or race an external reactivation. The maintenance view owns unserialized cleanup mutations while its transaction is held. A per-loop quiesce signal wakes an update/removal already queued behind that transaction instead of letting a firing timer and maintenance wait on each other's lock; cleanup retains and ultimately removes the durable row. A caller-authorized arm carries a commit-time session predicate into add(), evaluated only after it owns this transaction, so an arm validated before cleanup cannot recreate the archived slot afterward. Startup holds the lock across load, repair, timer arming, and singleton publication, while concurrent maintenance waits and then reuses the published live service. An offline view never arms timers or publishes the singleton. deactivate_and_wait() persists an inactive restart marker and waits for both the timer captured before the update and any replacement installed while that update waited. Administrative recovery removes the marker only after its dependent worker cleanup succeeds. Persistence is the commit point for every mutation: failed add/update writes restore the prior live loop and timer state, while failed removal restores the in-memory row (and its timer when active), leaving the same durable view visible for an immediate retry.
Status/System: /api/status, /api/system (live metrics, 1s refresh, static fields cached), /api/stream (SSE), /api/ws (WebSocket — single multiplexed connection replacing SSE + polling for React SPA)
Project git: GET /api/project/git?path=<dir> (checked-out branch label for the activity panel). path is matched against the gateway's own known project directories (every live chat slot's project plus the recorded recent-projects list) using pure string normalisation (expanduser + normpath), with no filesystem access on the untrusted value; an unrecognised directory is refused with 403 plus an SEL audit record, and only the matched server-held string is stat'd. Response is {"repo": false}, {"repo": true, "repoRoot", "branch"}, or the detached form {"repo": true, "repoRoot", "detached": true, "head"}, plus path on the envelope. No git subprocess is spawned; the branch comes from reading .git/HEAD directly (a git invocation parses repo-local config, which can carry [include] path = <file> and read an arbitrary file, so reading HEAD ourselves removes that class), and a linked worktree's .git pointer file (gitdir: <path>) is followed to the real HEAD. Both metadata reads go through hooks.safe_read_prefix (realpath canonicalisation, sensitive-target rejection, O_NOFOLLOW, bounded read). branch, head, path, and repoRoot all pass through redact before being returned; the SEL audit keeps the real path. Every failure mode degrades to "no branch" rather than erroring, since the only consumer is a decorative label. A detached HEAD gets a fixed 7-char prefix, not git's dynamic abbreviation. GET /api/project/git/status?path=<dir> and GET /api/project/git/log?path=<dir> use the same known-project boundary. The status route first checks os.path.isdir and repeats that check after any subprocess failure; a missing or non-directory project returns 200 {"repo": false, "files": []}. It performs no in-process inspection of .git or anything .git points to. Instead it runs sandboxed Git with LC_ALL=C and LANGUAGE=C: a bounded git rev-parse --git-dir probe owns repository discovery. A nonzero probe whose bounded stderr contains Git's not a git repository verdict is confirmed absence and returns 200 {"repo": false, "files": []}. Symlink markers, gitdir files, parent-repository discovery, ceiling and cross-filesystem rules therefore follow Git. Any other probe failure, including sandbox refusal, dubious ownership, permission failure, timeout, kill, or missing executable, returns 503 with code: "git_status_unavailable" unless the project directory disappeared. After a successful probe, a nonzero git branch --show-current (which validates corrupt symbolic HEAD while accepting detached and unborn states), a failed or empty git rev-parse --show-toplevel, or a nonzero git status is also 503. The filter-driver refusal is 503 too, not an empty listing, and it carries its OWN code. A repository whose own config names a filter.*.{clean,smudge,process} driver -- anything git lfs install --local touched -- is refused because git status re-hashes modified files through a clean driver on every 5 s poll, and the probe refuses under its own separate cause when it cannot prove a driver absent. The match is deliberately WIDER than execution: smudge fires on checkout rather than on the status re-hash, a driver that no .gitattributes path maps to never runs at all, and the probe-failure branch refuses without knowing a driver exists. Refusing all three is right, since none of them can be proven safe -- but the user-facing message therefore says the config DECLARES a driver and that policy refuses the check, and never claims a program ran. Spelling that refusal 200 {"repo": true, "files": []} makes it byte-identical to a genuinely clean repository, so the panel draws its green clean badge over a working tree whose status was never read; the panel's one job is surfacing uncommitted changes, and a confident "none" is worse there than no answer at all. The code is git_status_filter_refused, distinct from the git_status_unavailable its four siblings use, because this condition is not an outage: the cause is knowable, and for the declared cause it is permanent for that repository with no retry clearing it while the config stands (the unreadable cause is the one that can clear on its own, which is why only the declared copy promises permanence). One code for both would tell an LFS user their repository is broken on every poll forever. The panel renders it as a SINGLE ErrorNotice whose message names the cause, rather than the two generic notices the outage path shows: both routes refuse together because the cause is repo-level, so two boxes would report one condition twice. The refusal notice is TITLED and the outage notice is deliberately untitled -- titling both put two red boxes opening on the same word beside each other while they carry opposite retry semantics, so the difference between permanent and transient was a body-read away; titled against untitled separates them at a glance, and the outage title was restating its own first clause anyway. The refusal title names the OUTCOME ("Kiro can't check this repository for changes") rather than the mechanism: "status checks are off" collided with the outage string's "couldn't read the repository status", so one panel used the word status for two conditions of different severity. A coded status refusal reaching the non-coalesced branch renders that same title and localized copy, because the backend's English sentence under a localized heading is mixed-language copy in the other twelve catalogs. The header REFRESH control is also inert while BOTH routes refuse, and stays live for an outage: the refusal copy promises that retrying will not help, a re-fetch returns the same 503 every time, and a live control over that sentence contradicts it. It is disabled rather than hidden so the header does not reflow, and the glyph itself switches to the slashed variant, because dimming plus a hover tooltip did not tell a reader the control was dead. It is keyed on both routes rather than on the coalescing predicate deliberately: the refusal is permanent only while the repository config stands, and the notice's own agent hand-off invites changing it in the same window. The status query then recovers on its own 5 s interval while the log query -- no interval, 30 s staleTime, and a branch-change effect that cannot fire on the FIRST successful status because it needs a previous marker -- still holds its cached refusal. Keying the inert state on the coalescing predicate would therefore disable the one control able to clear that, leaving a notice claiming history cannot be shown while the changes list renders beneath it. The panel also refetches the log automatically as soon as the status route comes back CLEAN (not merely stops refusing: a status route failing for its own reason proves nothing about the config). The 503 body carries a cause discriminator alongside the code -- declared when repo config names a driver, unreadable when the probe could not prove one absent -- and the panel renders one localized sentence per cause. One code, because it is ONE condition (refused by policy) with two causes; a disjunction in the copy ("declares a driver, or could not be read") made the common case, an LFS repository on every visit, read a question the guard had already answered, and a reader could only partly follow it. Naming a driver as fact in the probe-failure branch would still be wrong, which is why that branch answers its own cause rather than borrowing this one. It stays an ErrorNotice with the askAgent hand-off because the string originates in a {error, code} body, which is what the errors-use-error-notice rule scopes on, and because a repo-supplied filter driver is precisely what the agent can explain or change -- routing it around that surface would strip the one affordance that helps here. HEAD is still validated BEFORE the filter guard, which test_status_checks_corrupt_head_before_filter_refusal asserts by spying that the guard is never reached, and the two codes now witness the ordering as well. A successful listing is capped at 500 entries and carries truncated: true when entries were dropped; the panel renders that as a capped count (500+ uncommitted) plus a showing first 500 note on the Changes header, because without it the cap read as the total and an undercount was presented as a count. /api/project/git/log applies the same filter-driver refusal and answers it with 503 code: "git_log_filter_refused", since an empty commit list is what a repository with no commits legitimately returns. A nonzero git log deliberately stays 200 {"repo": true, "commits": []}: an unborn repository exits 128 there, so that path is genuinely ambiguous and separating it would need the stderr match the status probe uses. The Git panel treats only repo: false without a simultaneous status error as a terminal no-repository state: it replaces the loading label, hides the clean badge and clean-repository empty copy, and tells the user to click the folder name below the message box. The other two ['git-status', …] consumers now receive 503s on operational failure where they got 200s, and each was checked rather than assumed. FileBrowserRail renders an inline ErrorNotice with the agent hand-off on an error in All mode, and deliberately not in Changed mode because the Pierre tree owns the same query failure there. PierreWorkspaceTreeImpl treats a status error as terminal for a Changed-mode load and renders that notice with a structured report. Both of those notices RECOGNISE the refusal codes and render the panel's own refusal copy rather than their generic failed copy, because the refusal is permanent for an LFS-configured repository: generic copy there would say "failed" on every 5 s poll, forever, with no cause and no statement that retrying cannot help — the exact outage spelling this refusal code exists to end. The recognition lives once in website/src/utils/gitStatusError.ts (gitErrorCode, isGitFilterRefusal, gitFilterRefusalCause, gitFilterRefusalCopyKey) and all three components read it, so one condition has one wording rather than three to keep in step. Neither of those two carries the panel's TITLE, deliberately: ErrorNotice's inline variant lays title and message out as flex siblings, so at the rail's 300-520px and the tree's width the title wraps into a stack of two-word fragments beside a narrow column of message — visible in capture-git-filter-refusal-surfaces.mjs's frames, which is what settled it. The title exists in the panel to separate a refusal from a coexisting outage notice, and neither of these surfaces renders a second notice beside this one. The status outage string also has a variant used when the log route failed too, which names the half it covers ("Couldn't read the changes list."): the default wording promises a commit list that is still on screen, so beside a notice saying history cannot be shown the two boxes first contradicted each other and then, once the clause was merely dropped, read as one failure reported twice. Naming each half is what separates them. Neither of the two reads non-200 as "not a repository"; both read repo only from a 200 body, so a refusal reaches them as absent data rather than as a negative answer.
Project tree: GET /api/project/tree?path=<dir> (workspace file listing for the Files tab's Pierre tree, and for a chat side-panel folder tab whose path IS the chat's project root — FolderPanel renders the same PierreWorkspaceTree there so descending expands in place instead of re-targeting the tab, and falls back to its one-level browseFiles listing for every other directory and whenever this endpoint does not answer, because a directory path chip can name any directory on the gateway while this allow-list deliberately does not). File tabs start with their optional project-tree rail hidden so the document receives the full panel width; the toolbar toggle and persisted mc-files-rail-open preference keep it available on demand. Same known-project allow-list, sensitive-path refusal, and SEL auditing as /api/project/git. Inside a git repository the listing is git ls-files -z --cached --others --exclude-standard with cwd=<dir> (tracked + untracked, .gitignore honored; a project dir that is a repo subdirectory lists only its own subtree); outside one it is a complete os.walk that skips hidden and heavy directories (_PROJECT_TREE_SKIP_DIRS). Response {root, paths: [<project-relative POSIX file paths>], directories: [<project-relative POSIX directory paths>], repo, truncated?, truncatedDirectories: [<direct parent directories whose files were omitted>]}. paths carries at most _PROJECT_TREE_MAX_ENTRIES (10,000) files, allocated round-robin across their direct parent directories; directories carries the complete uncapped directory skeleton so a folder row never disappears at the file limit, including empty directories on the filesystem-walk path. The frontend passes those directories to Pierre as explicit trailing-slash paths, keeps the workspace-level truncation notice, and marks each directory named by truncatedDirectories. truncated remains true exactly when files were omitted. Older clients may ignore both additive arrays. root, every file path, every directory path, and every truncation marker pass through the same segment-aware redaction before egress. Lazy expand-on-demand loading remains the long-term alternative when a complete directory walk itself becomes too expensive.
Theme / display: GET /api/theme/boot (unauthenticated — same boundary as /api/health; returns {mode, color, language, onboarded, import_onboarded} so the SPA can apply the workspace theme, pick its UI language, and resolve onboarding gates before the token flow completes; no secrets), GET/PUT /api/config/theme (read/persist workspace display prefs {mode?, color?, language?, onboarded?, import_onboarded?}; mode restricted to ""/dark/light/system; language is "" (auto-detect from the browser) or a BCP-47 tag validated for shape only via _LANGUAGE_TAG_RE, so adding a shipped language stays a frontend-only data change). All four response sites are built by one helper (handlers/core.py::_theme_payload) so a newly-added display pref cannot be surfaced by some and omitted by others — see config.md → "Dashboard UI language".
Foreign-agent import: authenticated GET /api/onboarding/import/scan, POST /api/onboarding/import/apply, PUT /api/onboarding/import/state; see "Foreign-Agent First-Run Import".
Memory: GET/PUT /api/memory/preferences, GET/PUT /api/memory/projects, GET/PUT /api/memory/history, GET/PUT /api/memory/settings (consolidation config: history_idle_hours, history_max_days; writes to config.json, applies immediately to running consolidator). The three markdown pairs, the semantic and episodic routes, stats, events and carve also take an optional ?store=<name>: absent, the route answers from the global store regardless of X-Session-Key; present, it is owner-gated, an undeclared name is 404 unknown_memory_store and a silo whose vector tier cannot be stood up is 503 store_unavailable. settings is install-wide and takes no store.
Memory stores (memory_admin.py, every route owner-gated UNCONDITIONALLY rather than on the store parameter's presence, so omitting it does not open the route to a non-owner; each mutation SEL-audits its own outcome): GET /api/memory/stores (the picker's list — per store name, is_default, lineage, exists, the three counts, facets_supported, backup_count, newest_backup; best-effort per store, so one damaged silo reports exists: false with null counts instead of failing the response, and the order is declared_store_names()'s — default first, then sorted), GET /api/memory/retired?store=&limit= + POST /api/memory/retired/restore (undo a supersession retirement in the store it happened in), GET /api/memory/backups?store= + POST /api/memory/backup + POST /api/memory/restore (list, take and restore that store's hot copies; a backup is addressed by its name, never by a path, and restore resolves that name inside the store's own backup directory). Rationale and the security argument for the store parameter: memory-skills-hooks and security.
Cron: GET /api/crons (includes last_run_ts, has_result, has_slot, hide_in_chat, folder_id, chat_folder_id, per-job timezone, top-level server_tz, skip_dates), POST /api/crons (create, optional agent, hide_in_chat, folder_id, chat_folder_id fields; an unknown chat_folder_id is a 400 unknown_chat_folder), DELETE /api/crons/{id} (single delete; SEL-audited cron.remove with the job id and ok/not_found outcome — the audit lands before history cleanup so a history-store failure cannot lose the record of a completed delete, and it is exception-contained so a SEL construction failure can never report a completed delete as failed; the single-delete MCP tool cron_remove and CLI kirocrew cron remove emit the same cron.remove event, spelling the success outcome allowed to match their own cron.create/cron.update neighbours), DELETE /api/crons (batch delete; body {"ids": [...]}, ids de-duplicated, capped at 500, per-id failure isolation, returns {ok, deleted, failed} with ok true iff anything was deleted; history purged per removed job; single crons refresh push; SEL-audited), PATCH /api/crons/{id} (partial update: name, message, cron, every, agent (or agent_id), channel, approval_mode, silent, strict_schedule, hide_in_chat, folder_id, chat_folder_id, timezone), POST /api/crons/{id}/enable, POST /api/crons/{id}/run (immediate execution, returns {name}, non-blocking via create_task), POST /api/crons/{id}/to-chat (creates linked chat slot cron-{id} with linked_session_key="cron:{id}", hydrates from session history, reuses existing slot), GET /api/crons/{id}/script (read-only source of a script cron's callable: {source, file, function, truncated, reviewable, sha256} — reviewable says the rendered source equals the raw body verbatim (no redaction masking, lossless decode, not truncated), sha256 is over the raw bytes read, pre-redaction, and is what a grant approval echoes back as expected_source_sha256; the path is derived server-side from the job's stored script field — never from the client — re-validated by resolve_script_path and read through safe_read_file_bytes_nolink pinned to <config_dir>/crons/, capped at 256 KiB with truncation flagged; source is credential/exfil-URL redacted before leaving the backend; 404 for unknown job or a job with no script (code: job_not_found / no_script), 404 script_not_found for a missing file, 422 script_path_refused/script_read_refused for containment refusals, and the same refusals on Windows rather than a platform gate — the nolink chokepoint answers there too, refusing a reparse point at the final name through platform_compat.open_file_no_reparse and proving containment from the opened handle via GetFinalPathNameByHandleW; the blocking resolve+read runs off-loop in discovery_executor)
JSON body shape: every route above that reads a JSON body requires a top-level
object, and answers a scalar, array, or null the same way it already answers a body
it cannot parse at all. PATCH /api/crons/{id} and DELETE /api/lessons refuse with
400 code: "invalid_json"; POST /api/crons/{id}/enable and POST /api/crons/{id}/ack
fall back to their defaults, as they do for an unreadable body. Neither reads a field
off a non-object, which has no .get and no string key lookup and would surface as a
500.
Cron Folders: GET /api/cron-folders (list), POST /api/cron-folders (create {name} → {id, name, order}), PATCH /api/cron-folders/{folder_id} (rename {name}), DELETE /api/cron-folders/{folder_id} (delete folder, clears folder_id on assigned jobs)
Messaging: POST /api/send-message (send to Slack DM + dashboard notification; body: {text, title?, blocks?}; when blocks provided, sends Block Kit message via post_blocks() with text as fallback; used by send_message MCP tool in kirocrew-core)
AutoNudge (autonudge.py, autonudge_authz.py, dashboard/handlers/autonudge.py): GET /api/autonudge (list loops — EVERY record the service holds, active or stopped, prompt loops and STRUCTURED MONITORS alike, as asdict(NudgeLoop), so stopped_reason, next_due_ts and banner ride the list even though the autonudge_state websocket frame carries only the live counters; a structured monitor is REDUCED here rather than returned verbatim, because these two reads carry no owner gate and so publish only what a caller reaching them is entitled to — that something is monitoring this session, roughly how often and in what state, never WHAT is being watched and never how far in (see below). Withheld as owner-scoped: the whole monitor record (monitor_state_public_dict: target, objective, kind, budgets, wake instructions, the provider-controlled observation payload, every fingerprint), plus message — which for a structured monitor IS the wake instructions, since both arming paths set message=monitor.wake_instructions or "structured monitor", so the innocuous-looking legacy field is the one that would leak them — plus banner (same class of agent-authored display text) and stop_sentinel_path. max_cycles, cycle_count and last_fire_ts are NOT withheld: the structured path never writes them on the loop, but each has a truthful equivalent in the monitor's own state, so they are MAPPED — max_cycles from budgets.max_agent_turns (a legacy "cycle" IS a delivered turn), cycle_count from agent_turns, and last_fire_ts from last_completed_at, which autonudge.py writes in the same step as wake_count += 1 (0.0 renders "never", truthfully). NOT last_probe_at — a probe that woke nothing is not a fire. Withholding them was WORSE than an imprecise truth, verified in a rendered pod: the popover defaults an absent max_cycles to 0 under a label reading "0 = ∞", so the panel asserted a budget-bounded monitor runs forever. None of the three crosses the entitlement boundary — each is the automation's own accounting rather than a fact about its subject, and this route already publishes max_runtime_secs straight from budgets. Nothing withheld is replaced by a null, an empty string or a plausible default: an absent field reads as "this surface does not carry that", where a faked one reads as fact. A monitor_presence object (probe_count, wake_count, outcome) was prepared and deliberately NOT shipped here: it would have had no reader, since the popover rendering that would display it is sequenced separately, and a field whose arrival AND display can be tested in one change belongs in that change. A consequence a reader must know: a reduced row carries no positive marker saying "this is a monitor" — it is told apart by which fields are absent, which is weaker than a marker and is part of what the rendering change should add. /api/monitors and its per-slot sibling, both behind _require_monitor_owner, remain the only place the full record appears. A GATED prompt loop is untouched by all of this: it delivers down the legacy path, so its message and cycle accounting are real. Withholding structured monitors entirely, and nulling one out of the per-slot read, is what made the goal popover report nothing armed while a monitor was probing; the Crew Members drawer's per-member patrol block is a reader of this list, filtered by the member's member-<slug> slot key, and the frontend re-reads the list on every frame and on every reconnect rather than merging frames. Reserved, not yet emitted: the response MAY carry denied: [{slot_key, code, reason, ts}] — at most one entry per slot, the most recent REFUSED arm, and a SUCCESSFUL arm on that slot clears its entry, so a refusal can never mask a later stop; code is machine-readable in the same convention as non-2xx error bodies, drawn from the arm chokepoint's refusal branches — member_mode (a crew/member slot armed by anything other than itself — the self-arm exception below is the one admitted case), memory_mode (incognito/temporary session), session_gone (owning slot no longer exists), message_too_long, sentinel_path_sensitive, audit_unavailable — and reason is that branch's own error text. Today refusals are recorded only in the SEL audit and the field is absent; a reader treats an absent field as "no refusal recorded", never as an error, and the frontend renders no refusal verdict until a producer ships), GET /api/autonudge/slot/{slot_key} (loop for a slot), POST /api/autonudge (start/replace a loop), PATCH /api/autonudge/{loop_id} (update), DELETE /api/autonudge/{loop_id} (stop), POST /api/autonudge/{loop_id}/fire (run the loop's next cycle NOW, out of band from the idle countdown). The fire route arms rather than delivers, and writes nothing: AutoNudgeService.fire_now only re-arms through _arm_timer(delay=0.0), so the cycle runs inside the ordinary _timer body and the stop sentinel, the cycle cap, the wall-clock budget, the approval-stall stop and the probe gate all still apply -- delivery goes through the one _on_fire path, and a manual cycle therefore advances cycle_count exactly as a scheduled one does. _timer sleeps the delay it is given and fires WITHOUT consulting next_due_ts, which is why arming alone is sufficient. delay=0.0 rather than _arm_from_deadline's _OVERDUE_REARM_SECS beat, because that beat exists so an elapsed deadline does not ambush a user mid-conversation and a manual press IS the user asking. next_due_ts is deliberately NOT written, and the method has no await at all. An earlier revision wrote the deadline and awaited a durable persist so a restart between the press and the fire would resume overdue; that await was a suspension window, and several writers to next_due_ts in this module hold no lock while writing it (the quiet-tick re-arm on the gated-wake branch is one), so each guard closing one writer's window exposed the next -- a concurrent remove arming a stale object, a cancelled caller abandoning the write, a countdown entering _firing mid-persist, a quiet tick overwriting the deadline, then the refused path leaving its own value committed. The window is not closable at that call site, so the write was removed instead: the cost is that a restart between the press and the fire resumes on the loop's own schedule rather than overdue, the same degradation _persist_soon already documents for every other deadline assignment here. The popover supplies the "due" reading locally so a press still shows its one confirmation, reconciled by the next autonudge_state frame. The interval reset is INHERITED, not implemented: a delivered cycle clears next_due_ts and the re-arm starts a fresh full interval, so the next automatic nudge lands one idle_secs after the manual turn ENDS, and notify_user_input still defers rather than cancels. Refuses a loop it does not hold (404 -- the stop sentinel lands here too, since it removes the loop), an INACTIVE loop (409 -- the non-removing terminal bounds all leave the loop registered but inactive, so one condition covers them and a press cannot buy a turn past a bound the user armed), a loop MID-FIRE (409 -- _arm_timer would cancel a task that may be parked on _persist_locked() writing the delivered cycle, the same window notify_turn_complete defers around), a structured monitor (409 structured_monitor_requires_monitor_api, as PATCH does), and a session with a turn in flight (409 session_busy). That last one is REFUSED RATHER THAN QUEUED, which is the fire path's own recorded decision -- queueing "would stack identical 3KB+ nudges and blow up the context window" -- read through the canonical two-term predicate slot.running or slot._in_stage_execution (slot.running alone is False between the stages of a multi-stage plan). The busy pre-check is an AFFORDANCE, not a guarantee: a turn starting between it and the fire is still refused by the fire path, so the check exists to turn that silence into a 409 the goal popover can show. The fire itself is AUDIT-OR-DENY (autonudge_fire, critical=True, off-loop) because a delivered cycle spends a model turn; the refusals are audited best-effort, since the request is refused either way. Loop-by-id resolution — the DELETE handler's pre-remove audit capture and the PATCH-path channel-banner refusal — shares ONE accessor, svc.get_by_id(loop_id) (returns the live loop or None), alongside get_by_slot/list_all, rather than two inline id-scans. Loop creation goes through the single chokepoint authorize_and_add_nudge(svc, state, slot_key, message, …, source) — a transport-agnostic security module at autonudge_authz.py (NOT the HTTP handler file; state is a narrow structural Protocol, and dashboard/handlers/autonudge.py is a thin HTTP mapping that re-exports it) — shared by the REST handler AND the workflow ctx.nudge bridge, so both enforce identical authorization before svc.add: dashboard slot_key in state._slots; Slack session routable; Discord deny-by-default (transport up, DM session only, user in allowed_user_ids, and slot_key == the user's current session key — blocks spoofing another discord: session); 8000-char message limit; sensitive stop_sentinel_path refused. Load-time sentinel repair (repair_sentinel_path): the kill-switch path is resolved once at arm time and persisted verbatim, so the loop store can outlive the one-time ~/.kiro/crew→~/.kiro/crew data-home migration and start() would re-arm a loop whose sentinel points at a directory that no longer exists — _timer only tests Path(stop_sentinel_path).exists(), so that loop's kill switch is dead (only max_cycles or an explicit remove can stop it). _load() therefore re-homes any legacy-rooted path onto the resolved current home and re-applies the arm-time sensitivity refusal (a persisted path that is sensitive now is dropped to "" rather than stat'ed on a timer); a repair sets _store_dirty so start() flushes it once via an executor-offloaded write instead of re-deriving it every boot. The check deliberately does NOT require the path to live under the data home — an absolute workspaces.<name>.dir is a legitimate configuration, and filtering on containment would clear working kill switches. PATCH /api/autonudge/{loop_id} applies the SAME message redaction as the arm chokepoint (redact_exfiltration_urls + redact_credentials) — the field it mutates is the one that gets persisted and re-injected/posted on every fire, so redacting only on arm would make update a trivial bypass — and it SEL-audits denied outcomes (non-string/oversized message, non-integer idle_secs/max_cycles, non-boolean active — bool("false") is True, so a string would turn a pause into a resume — unknown loop), not just success. Both the redaction and the audit live in the transport-agnostic authorize_and_update_nudge chokepoint beside the arm one, not in the HTTP handler, so no future non-HTTP caller can bypass them; it too is audit-or-deny (critical invoked event before the mutation, 503 if unwritable). Update/fire write serialization — one protocol, two entry points: every writer must snapshot the store while holding _lock and then await an executor-offloaded _write_state (fsync must never run on the loop). _update_locked and _add_locked do that inline because they already hold the lock for their mutation; the post-fire bookkeeping, which holds no lock, calls the _persist_locked helper to acquire it first. A writer that snapshotted and then released could otherwise land a stale payload over newer cycle_count/active state and resurrect it after a restart, so update() is shielded like add() to keep the lock held across its own offloaded write. One timer-cancellation policy (_cancel_timer): two conditions make a cancel wrong rather than redundant, and both live in that one method so no caller has to remember either — the currently running timer task (a self-re-arm from inside _timer) is about to return on its own, and a task whose event loop has already CLOSED must be dropped rather than cancelled. Task.cancel schedules through loop.call_soon, so cancelling a task on a closed loop raises RuntimeError: Event loop is closed, which escaped remove/remove_sync and made the dashboard handler above it answer 500. The service is a process-global singleton (_INSTANCE, published by start()), so its _timers outlive the loop that created them whenever one loop replaces another: the gateway's own shutdown, and — the way this surfaced — a test driving a handler after an earlier test's loop closed. The condition is asked positively (get_loop().is_closed()) rather than by catching the RuntimeError, because a closed loop is the one state where cancelling is a no-op by definition and catching would also swallow a genuine scheduling fault. stop() routes through it for the same reason rather than looping over bare t.cancel() — shutdown is the likeliest moment for a timer's loop to be closing already. The closed-loop question is asked BEFORE the current-task one because it needs no running loop of its own: stop() is reached from synchronous callers (gateway shutdown, test teardown) where asyncio.current_task() raises RuntimeError: no running event loop rather than answering None, which _current_task_or_none turns into the None the caller means. The test-side floor for the singleton is _restore_autonudge_singleton (see testing-conventions). Mid-fire updates never cancel the timer (_firing tracks the fire window): channel loops run the unattended turn inline in _on_fire, so cancelling would kill the turn — the re-arm is deferred to the running timer, and the undelivered path refuses to re-arm a loop that was deactivated mid-fire so an explicit pause is not silently resumed. The window spans delivery, the bookkeeping persist, and the re-arm decision (_run_fire_cycle), and notify_turn_complete observes it too: a dashboard turn that completes mid-window records the re-arm in _rearm_pending and it is applied when the window closes, because arming immediately would cancel the firing task while it is writing the delivered cycle. monitor_update MCP tool: revises the loop bound to the calling session in place (message / idle_secs / max_cycles) so a stale instruction can be corrected without tearing the loop down and losing its cycle_count. It resolves the loop id from the caller's own binding key rather than accepting a caller-supplied id, so a cross-session rewrite is unrepresentable. Since issue #755 it is STATELESS (see session.md → "Stateless session-directive tools"): the tool validates its patch and returns a directive, and the session-aware consumer resolves the loop from ITS OWN binding key via svc.get_by_slot(binding) (session_directive_apply.py), never via GET /api/autonudge/slot/{key}. The tool still calls _resolve_session_key_strict() (no PID walk), but only as a context guard that rejects un-appliable sessions (cron/hook/subagent) — not to bind the effect — for the same reason monitor_start/autonudge_stop do. monitor_start cycle cap: omitting max_cycles now yields a bounded default (_MONITOR_DEFAULT_MAX_CYCLES = 24) instead of unlimited, because an unbounded loop stops only when the model volunteers autonudge_stop and observed loop stores show that is unreliable (real babysit loops ran to 24/24 and 20/20 delivered cycles, stopping only on the cap); explicit 0 still means unlimited. Wall-clock budget (max_runtime_secs): a second, opt-in terminal bound (0 = unlimited, ceiling MAX_RUNTIME_SECS_CEILING = 604800s/7 days — enforced at BOTH authz chokepoints, not just the MCP schemas, so REST/workflow callers cannot exceed it, and the REST handler refuses non-integral floats rather than truncating them) alongside the cycle cap, because a cycle cap alone cannot bound COST — a loop with slow turns or a long idle gap can run for days within its cycle budget. Measured from the persisted created_ts (not arm time), so a gateway restart re-arms the loop but never resets its clock; a store entry with no created_ts never trips (no anchor — guessing one could kill a healthy loop on its first post-upgrade cycle). Enforced in _timer via the shared runtime_budget_exceeded predicate, checked AFTER the cycle cap (both exhausted → the cap wins) and BEFORE the fire so a spent budget never buys one more unattended turn, and re-checked immediately post-delivery in _run_fire_cycle — a budget that expired during a slow turn deactivates the loop the moment that turn ends instead of arming another full idle cycle. The service never cancels an IN-FLIGHT turn (the mid-fire contracts above forbid it — cancelling kills channel turns), so the declared bound can overshoot by at most one turn, itself capped by the transport's per-turn ceiling (constants.CHAT_TURN_TIMEOUT); terminal treatment matches the cap — deactivate (inspectable/restartable, not removed) + expired event, and the notifier (_notify_nudge_expired) words the notification per bound using the same predicate. A terminal subject outranks every bound in that wording, and an OWED terminal turn counts as one: a channel-bound loop does not settle on observation (it learns its watch finished from a delivered turn), so the probe records the owed turn in monitor.terminal_pending and leaves the loop active with no outcome and no monitor_terminal reason — and if that final turn is refused and the retry finds a bound spent, _timer deactivates on the bound before the settlement that would promote the debt ever runs. The notifier therefore derives terminal from stopped_reason == monitor_terminal or an outstanding terminal_pending, and takes the merged-vs-closed-unmerged distinction from the settled outcome falling back to the debt (both use success/blocked), so a watch whose subject merged is never announced with the signal of one that ran out of cycles. This is WORDING only: the bound that stopped the loop keeps its own stopped_reason, so the spent cap stays observable and the monitor_update revival affordance keyed on cycle_cap is unchanged. Whether an owed terminal turn should instead outrank the cap in _timer and be DELIVERED is a separate, open question (issue #8060), not settled here. Approval-stall stop (approval_stalled): a third terminal condition, evaluated in _timer LAST of the three so a loop also out of cycles or budget still reports the bound it historically would have. It is reactive by construction — it fires only on recorded evidence that a cycle's tool approval went unanswered, never on a reading of whether an auto-approve grant is in force. The evidence is written by notify_approval_stalled(slot_key), a sync slot-keyed hook alongside notify_turn_complete/notify_user_input, called from the approval-timeout branch of ALL THREE paths a nudge cycle can stall in, matching the three fire paths _run_fire_cycle dispatches: chat_runner's per-slot wait (dashboard-slot loops), _interactive_approval's raced wait in the Slack gateway (Slack loops, whose turns are approved there rather than through the dashboard runner -- the fire path threads the loop's own slot_key in as nudge_key, and the guard keeps cron/taskrunner/subagent consumers of that same callback unaffected), and DiscordApprovalDecider.__call__'s button wait (Discord loops, keyed on the decider's own session_key). Only the timeout branch records; an explicit human rejection is a decision, not evidence that nobody is present. The hook sets the persisted approval_stalled flag and returns rather than stopping inline, because stopping there would cancel a possibly-mid-fire timer -- the one thing the fire-window contracts forbid -- and race the turn that produced the evidence. The predictive alternative (test the grant before dispatching) was rejected: a loop whose cycles only touch auto-approved tools needs no grant, so it would be stopped for nothing, and such a loop can never reach an interactive approval wait, which is what makes the reactive test free of that false positive rather than merely tuned against it. Cost is bounded at the one cycle already in flight. monitor_update deliberately has no revival affordance for this reason (raising a bound does not restore an authorization), but its paused-loop denial names the real remedy — re-enable auto-approve, then monitor_start — and every revival clears the flag so a re-granted loop is not stopped by spent evidence on its first wake. The clear is keyed on an actual revival (not was_active), not on any active=True: a still-active loop also receives one from an ordinary settings save, and treating that as an answer would erase evidence recorded moments earlier and let one more doomed cycle fire. Before this existed, a loop whose grant lapsed kept waking, dispatching, being declined and spending its cap on cycles that were never able to act, with cycle_count making a capped-out run indistinguishable from a finished one; merged reporting (an operator notice on grant expiry) explained that after the fact but never stopped it, and being edge-triggered on the expiry event could not cover a loop that started after the grant was already gone. stopped_reason (persisted on the loop: ""/"manual"/"autonudge_stop"/"cycle_cap"/"runtime_budget"/"approval_stalled"/"structural_terminal") records WHY the last deactivation happened — _timer's terminal bounds tag themselves, the fire path tags structural_terminal when a delivered turn was rejected as structurally malformed (deterministic; re-firing the same context cannot help), any other update(active=False) defaults to "manual", and every revival clears it. The autonudge_stop session-directive applier persists "autonudge_stop" only for research-* loops, whose watchdog consumes that source-owned evidence; ordinary dashboard and channel loops retain the historical remove-on-stop behavior instead of leaving a paused record with no consumer. A Research Lab stop may replace an earlier manual pause from an app-disable race, while cycle/runtime-bound writers still cannot overwrite a deactivation that landed first. The caller’s free-form explanation is deliberately not persisted because the watchdog needs only the deterministic source and model-authored text may contain sensitive content. The Research Lab record is restart-durable until consumed. Its watchdog checks the tombstone before trust-expiry handling or reviving loops suspended by an app disable: on the first watchdog poll after any in-flight worker turn exits, it prefers the tombstone over worker_done.json, preserves the existing verified-finding-first verdict, requires at least one readable finding before reporting STOPPED, then removes the consumed loop record. worker_done.json remains the conservative fallback at the normal idle deadline when no tombstone exists, while mere loop absence remains untrusted because unreachable-session cleanup also removes loops. Revival via monitor_update keys on a paused record's source, NOT on elapsed-time inference: wall-clock keeps growing after a manual pause, so "budget looks spent" cannot distinguish a pause from an expiry, and a budget raise must never resume a loop the user paused (the cycle-count heuristic survives only as a legacy fallback for stores written before the field). A budget-stopped loop revives when the budget is raised above its elapsed age (or 0), matching the cap-raise affordance; the paused-loop denial names the bound that actually stopped the loop. Arm-outcome reporting (two channels): the MCP tool's own ack is deliberately non-committal (arming happens when the turn loop processes the tool result, after the tool has answered), so the stateless applier (session_directive_apply.py::_monitor_start / _monitor_watch) reports the outcome where it is known. Channel 1 is the applier's return string, which overwrites the transcript tool_result row: on success Monitor loop <id> started on this session: <cadence> …; first wake in ~Ns (HH:MM:SS UTC) (the wake time is read off the ARMED record's next_due_ts — a full idle_secs after arming — never off the request), on refusal Failed to start monitor loop: {error} [status N] (409 mode/existing-automation, 404 session gone, 503 audit unavailable), or Monitor loop NOT armed: auto-nudge is disabled on this host.. Channel 2 is a notice row appended through append_and_surface on the same session: ✅ Automation loop armed: loop <id> · every <interval> · no cycle cap · first wake in ~Ns (…) or ⚠️ Automation loop NOT armed: <reason> [status N]; slot-less (channel TurnDriver) callers have no row and keep the string. Historical note — the applier's explicit outcome strings replaced the former undifferentiated string that was indistinguishable from the transient MCP reconnects agents are instructed to retry through. Auditing is preserved in intent: the applier emits a SEL event for every outcome tagged source="mcp-directive" (success / denied / error), and the POST /api/autonudge REST handler and the workflow ctx.nudge bridge keep their own source="dashboard" / source="workflow" audits through the shared authorize_and_add_nudge chokepoint. AUDIT-OR-DENY availability policy (deliberate contract change): a CRITICAL invoked event is written (synchronously on a worker thread, awaited) before svc.add arms the loop; if that write fails, the arm is denied — POST /api/autonudge returns 503 ("audit log unavailable — nudge loop not armed") and the workflow path reports "NOT armed" into the run. Previously SEL unavailability could not prevent arming a loop; now a wedged SEL trades availability for a guaranteed audit trail (no loop may ever exist unaudited), matching the repo's fail-closed security posture. The terminal success event is best-effort — an armed loop is already covered by the invoked record. add() ordering semantics (observable by all callers): add() awaits an executor-offloaded persist and arms the timer under a shielded task, so the loop's FIRST timer cycle may complete before add() returns — callers must not assume post-add() state predates the first fire. A caller cancelled mid-add() gets shielded "mutation may have already landed" semantics: it receives CancelledError, but the arm+persist completes (writes stay strictly serialized under the service lock). binding_key_for(session_key) (in autonudge.py) is the single source of truth for "nudge-able" (dashboard:chat-N-TS→chat-N-TS; slack:/discord:/webex: pass through; cron:/hook:/subagent:/empty→None), shared with the monitor_start directive applier (via _binding() in session_directive_apply.py). Workflow ctx.nudge: dynamic-workflow scripts arm a loop on their originating session — WorkflowService threads the launching session_key (start→run→_exec_validated→_RunContext) and wires a nudge port to a gateway-injected nudge_authorizer that maps the (caller-influenced) key via binding_key_for and calls authorize_and_add_nudge(…, source="workflow"); the workflows package never touches AutoNudgeService/state directly. Best-effort: an unwired authorizer, a non-nudge-able session, an authz rejection, or svc.add/no-loop failure degrades to a logged no-op (the arm runs as a create_task whose ref is held in WorkflowService._nudge_tasks to avoid mid-await GC) — a monitoring convenience never crashes a completed run. This fixes the prior RuntimeError("ctx.nudge is not available for this run (no nudge port wired)"). The workflow authoring prompt (_AUTHOR_SYSTEM in workflows/service.py) advertises ONLY ctx primitives production actually wires; a parity contract test (test_workflows_nudge_wiring.py::test_author_prompt_advertises_only_wired_primitives) fails if a primitive is added to the prompt without a wired port (or vice versa), so the advertised-but-unwired crash class cannot silently reappear. The same class is closed at the ENFORCEMENT layer too: the runner calls validate.check_ctx_surface(source, CORE_CTX_SURFACE | <its wired port names>) at the exec boundary, so a hand-written or rerun script referencing a primitive the executing host did not wire fails validation with a clear per-line error (run_failed, where="validate") instead of starting and dying mid-run — host-aware by construction, so test/companion runners that wire additional ports keep their full surface.
Research Lab worker slot keys have the canonical shape research- plus an
eight-character lowercase hexadecimal campaign id. Creation and recognition
share apps/builtins/auto_research/session_keys.py. A matching name alone is
not ownership evidence: only a canonical slot carrying the persisted
auto-research app provenance retains a stop tombstone. Prefix lookalikes such
as research-notes, and user-created canonical lookalikes such as
research-deadbeef, are ordinary dashboard slots and retain remove-on-stop
behavior.
Structured monitor substrate (monitoring/models.py,
monitoring/decision.py): NudgeLoop.monitor is an optional, versioned
controller record for probe-first monitors. Absence is the durable compatibility
marker for a legacy prompt loop, and serialization omits the absent field so an
unrelated save does not eagerly migrate old records. A prompt-driven zero-token
gate may also carry probe state, but it remains a legacy loop (gate=true) on
the AutoNudge control surfaces; only controller records (gate=false) use the
structured monitor APIs and recovery path. The record owns target and objective
identity and configuration generation, canonical observation and wake
fingerprints, typed in-flight delivery and completion-evidence deadline,
provider-error streak, completed agent-turn and token totals, probe deadline, and
terminal outcome. Load reconstructs the typed record; a terminal current-version
record with a contradictory active loop is deactivated. AutoNudge dashboard
responses recursively pass every string in the structured monitor mapping through
redact_via_context, including provider-controlled observation keys and values,
before they leave the backend. Legacy loop fields retain their existing response
shape. An unsupported monitor version is marked blocked in its compatibility view
and retained for inspection rather than executed under an older policy; its raw
future payload and outer active intent survive an unrelated store rewrite unchanged.
A generic legacy Save with active=true likewise leaves that future outer intent untouched. Its inert
compatibility view uses validated current identity values when
present and placeholders otherwise, so a future schema may rename those fields
without making an older reader delete the raw record. Its compatibility view is
inert under the older runtime without rewriting the outer active intent, allowing
a later compatible gateway to resume it. A malformed current-version
monitor mapping is quarantined inactive with invalid_monitor_record; its exact
strict-JSON monitor payload remains durable across unrelated store rewrites for
inspection. The dashboard keeps that quarantine view non-actionable so Restart
cannot replace the preserved payload. A non-mapping payload is skipped because it has no preservable monitor
identity. Structured cadence is typed state with a 300-second default;
the legacy loop default remains 60 seconds. This substrate has no delivery
dispatcher: loading, generic updating, arming, or a pre-existing timer all fail
closed without a model turn until the probe controller is wired. It does expose
a dormant completed-turn controller seam. An actionable fingerprint is persisted
in-flight before dispatch; unavailable delivery clears that claim without spend,
while busy delivery retries the claim within its runtime bound; a
correlated completion charges one agent turn exactly once, adds only reported
non-negative token counts, and records token usage as unknown when authoritative
counts are unavailable. Duplicate, removed, replaced, legacy, and mismatched
callbacks are no-ops. Recovery resumes an accepted in-flight wake toward its
persisted completion-evidence deadline and a BUSY claim toward its persisted retry
deadline. A user or budget stop that races an accepted action keeps or re-arms that
finite evidence deadline; completion still charges exactly once, while expiry clears
only the stale correlation and preserves the terminal outcome. A user stop writes its
terminal replacement snapshot before mutating the live record or cancelling its timer,
so a failed disk write leaves memory, disk, and the active schedule aligned instead of
allowing a later restart to resurrect work.
A legacy claim with neither typed delivery nor an evidence deadline
deactivates with completion_evidence_unavailable while retaining the
acknowledged fingerprint, so restart cannot duplicate the wake. Completion stops
on the first exhausted runtime, turn, or token bound (in that precedence), and
the completed-turn bound is validated against the universal eight-turn ceiling
when constructed or loaded. Approval-stall completion is terminal and budget exhaustion takes
precedence when both apply.
Claim, budget-stop, completion, and pre-turn dispatch-failure mutations are
applied to a staged copy; the replacement snapshot is persisted before the live
record or timer changes. A failed write therefore leaves runtime state aligned
with the restart snapshot instead of suppressing or releasing a wake only in
memory.
Dashboard structured actions receive a runtime-only completion hook and report
only from _run_chat's EVENT_COMPLETE branch when its reason is safe completion
evidence. ACP-synthesized terminal reasons are excluded from accounting. The
stale-stream compatibility path reuses end_turn for a synthetic completion, so
that reason is also excluded until ACP events carry provenance that distinguishes
it from a provider result. That correlated completion
is persisted before cancellable token-analytics I/O, so slot cleanup cannot lose
provider completion evidence after the event has arrived. Legacy dashboard nudge
scheduling still returns immediately and still rearms from the existing
notify_turn_complete finally; the completion hook neither replaces nor moves
that lifecycle call. The pure decide_monitor policy performs no I/O. It
checks the non-zero runtime/turn/token/provider-error budgets first, classifies provider errors
without a model turn, suppresses an unchanged observation, and permits
wake_actionable only when the actionable fingerprint differs from the last
acknowledged wake fingerprint. The defaults are 14,400 seconds, eight completed
agent turns, 250,000 aggregate input/output tokens, and three cumulative
provider errors. A successful probe resets retry backoff but does not refund the
finite provider-error budget. Because that budget is finite and never refunded, it
counts refusals the HOST gave this watch and nothing else: a tick that sent no
request is a third outcome (github_provider_errors.is_unattempted_probe, marked
by the provider_rate_limited_shared reason code a GitHub monitor sets when it
declines its own probe on the shared github:api schedule) and it moves neither
the cumulative count nor the consecutive streak. Charging it would retire a
healthy watch on the cadence of work that never touched its subject, and clearing
the streak instead would be the opposite defect — a provider failing on alternate
ticks with a cooldown between would blind the watch for ever. BOTH counting sites
separate the three, and they have to: shadow.py::apply_monitor_probe is the
shadow path, and autonudge.AutoNudgeService.apply_monitor_probe is the one a
wired controller drives, so a rule held in only one of them is a rule held
nowhere. Pinned on each — test_monitoring_github_shared_cooldown.py for the
shadow path, test_monitor_controller.py::test_a_shared_cooldown_skip_does_not_retire_a_healthy_watch
for the production one — and each pin also asserts that a refusal the HOST gave
still retires the watch on schedule, so the skip cannot be widened into a way of
surviving a real outage. This substrate does not itself schedule live provider probes or
expose a new MCP tool; those are later RFC implementation slices.
GitHub pull-request shadow probe (monitoring/github_pull_request.py,
monitoring/shadow.py): the first typed provider adapter accepts only exact HTTPS
pull-request URLs on public github.com (normalizing www.github.com); arbitrary
hosts, enterprise instances, repository URLs, path suffixes, query/fragment identity,
and non-positive pull-request numbers are refused before provider execution. The
adapter resolves and invokes the shared hardened github_runner boundary with
GH_HOST pinned to github.com. Every read is a GraphQL document carrying one alias
per subject, so the subjects of one call share the request and a tick
of at most 25 subjects costs three of them, each further chunk of 25 adding three;
owner, repository, number, and
cursor are bound as typed variables, never interpolated into the document. It reads
lifecycle, review, and mergeability fields first.
For an open pull request it reads statusCheckRollup in a separate request
paired with headRefOid, in pages of 100 up to four pages, then walks GraphQL review
threads in pages of 100, stopping
after at most ten pages, each subject advancing on its own cursor. A missing Checks
permission therefore cannot erase readable
primary facts, and a rollup for a different head — whether the head field or the
rollup's own commit is the one that differs — is incomplete instead of being
combined with the primary response. A null rollup is a complete empty check set.
Merged and closed primary lifecycle states are terminal without either supplemental
request, and such a subject is left out of the supplemental documents entirely.
An incomplete, malformed-node, repeated/missing-cursor, or capped thread
traversal is a pending fact and can never produce review-ready success. Outdated
unresolved threads are ignored because they no longer block the current diff.
When GraphQL returns errors alongside usable thread nodes, the traversal remains
incomplete but folds those nodes first, so an observed unresolved thread still wins
as actionable evidence. One unreadable subject degrades alone: gh exits non-zero
whenever a response carries any error, including one scoped to a single alias, so the
adapter reads the payload rather than the exit code and attributes each error to the
subject its alias names; an error naming no alias in the batch is charged to every
subject in it. If the supplemental review-thread request itself fails, the
adapter retains primary lifecycle, check, review, and mergeability facts and unresolved
threads observed on earlier pages while marking thread evidence incomplete. A known
blocker therefore remains actionable. Without a known blocker, incomplete checks or
threads remain pending. The supplemental failure is carried separately as a typed
provider error, counted against the bounded provider-error streak, and retried without
discarding the readable primary facts.
The durable observation is stable canonical JSON containing only normalized target
identity, head revision, lifecycle/draft state, sorted check identities by outcome,
normalized review/blocking state, unresolved-thread count/completeness, and
mergeability. Repeated check identities retain their multiplicity because display
labels cannot prove that provider rows describe the same logical job. Provider
ordering, timestamps, URLs, request/cursor ids, titles,
bodies, comments, and logs do not enter it. Provider-controlled check labels pass
through credential redaction, control-character removal, unconditional URL removal,
and fixed identity/count bounds before persistence. Overflow marks check evidence
incomplete rather than producing success. The
GitHub check-run and legacy status-context namespaces remain distinct. A workflow
name plus display name is not a stable logical job identity: independent jobs may
share both, and the rollup does not expose a stable workflow-file identity. Check runs
therefore remain independent so a newer success cannot hide a known failure from a
different workflow definition. Workflowless rows remain independent. Duplicate legacy
status contexts are folded by blocker severity, so a same-named success cannot hide a
failure. STALE check-run conclusions are terminal non-blockers. GraphQL owner,
and cursor variables use raw string fields; only the pull-request number uses typed
conversion.
SHA-256 fingerprint covers the compact sorted serialization; a collection reorder or
volatile provider value keeps it stable, while a new head changes it. Only a non-empty
current head may set the changed-head fact. A changed head does not wake while the
observation is pending. An otherwise-green changed head records one
wake_actionable; after that head is persisted and observed unchanged, the same green
state reaches terminal success rather than being suppressed as a duplicate.
Classification is conservative: merged is terminal success
(pull_request_merged), closed-unmerged is terminal blocked, draft (which takes
precedence over check failures), incomplete/pending or
unknown checks, incomplete review-thread evidence, and unknown mergeability remain
pending. Mergeability succeeds only for the explicit settled CLEAN, HAS_HOOKS,
and UNSTABLE states, so empty or future provider values fail closed as pending;
failed checks, requested changes, unresolved threads, conflicts, a behind head, and
concrete branch-protection failures are actionable. GitHub's generic BLOCKED
merge-state is pending because it also represents an otherwise healthy pull request
waiting for required checks or reviews; the specific check and review facts determine
whether the observation is actionable. A requested-changes decision or an unresolved
thread already observed remains actionable even when the unseen review-thread tail is
incomplete; incomplete evidence can prevent success but cannot erase a known blocker.
Rate limits and transport/provider
failures are retryable, while authentication, authorization, not-found, and local
gh setup/trust failures on the load-bearing primary request are terminal categories.
HTTP status is classified before repository text, while GitHub's 403 rate-limit forms
remain retryable. Resource-pressure spawn errors are retryable rather than
misclassified as setup. Supplemental errors of any category use the bounded retry
streak because the primary target remains readable. The shadow runner persists only
the canonical observation, decision, next-probe time, and aggregate probe/error
metrics. It checks hard budgets before provider execution, and terminal decisions
persist outcome, reason, stop time, and a cleared next-probe deadline. Persistence is
the commit point: a failed write leaves the live state
unchanged so the same observation remains eligible for retry. It has no action
dispatcher, never sets a wake fingerprint or in-flight claim, and raises before
probing or persisting when wake_delivery is requested.
This slice exposes no MCP control tool and spends no model turns.
Every persisted monitor mapping must encode as strict JSON; nested non-finite numbers and other values accepted only by Python's permissive encoder invalidate the record. Timestamp-like integers too large for finite float arithmetic are likewise quarantined with a zeroed compatibility timestamp while their exact raw monitor payload is retained. If permissive JSON decoding produces a non-strict payload that cannot itself be retained, the loader preserves the outer loop and replaces only its monitor view with a sanitized, inactive quarantine record; a malformed monitor cannot make the whole loop disappear.
A reasonless inactive update is idempotent for a Research Lab stop tombstone:
it preserves the source-owned autonudge_stop reason until the watchdog
consumes it. An API retry or unrelated patch therefore cannot downgrade a
deliberate worker stop into a revivable manual pause. The source-owned stop can
still replace an earlier manual app-disable pause.
Fresh Research Lab starts/resumes remove prior-run tombstone and marker evidence before publishing RUNNING. Tombstone removal precedes the potentially unbounded off-thread marker cleanup, so the watchdog can never observe a half-prepared new run. Direct _launch_loop callers preserve the same cleanup order before any service/state availability early-return.
The watchdog also establishes a run observation boundary before consuming a
Research Lab stop tombstone. The first watchdog poll for a new started_at
seeds the run baseline and defers settlement without re-arming the inactive
loop. A current-run tombstone remains prompt: when the watchdog observed the
run while its worker turn was still active, the first safe poll after that turn
exits can consume it immediately.
If that worker turn publishes a final cycle before it exits, the watchdog first
persists the advanced cycle count, emits the finding event, and advances recursive
exploration through the same bookkeeping path as an ordinary poll. Tombstone
settlement then classifies the terminal state, so its fast path cannot omit the
last finding or leave total_cycles stale.
Terminal classification and its SQLite/sidecar status persistence both run in
worker threads; neither the ordinary idle-deadline path nor the tombstone fast
path can block the gateway event loop while settling a campaign. Once terminal
classification is known, the watchdog first persists the captured loop as
inactive, then commits terminal status and durably removes that exact loop. A
failed removal-store write is retried against an idempotent pending-removal
postcondition. If the retry budget is exhausted, the original error propagates,
but the pre-commit inactive state prevents restart from re-arming the terminal
campaign. The transition remains cancellation-safe: shutdown waits for durable
loop cleanup before the watchdog exits.
Settlement and user actions serialize through a per-campaign transition lock.
The watchdog carries the started_at value of the run it observed and, after
acquiring that lock, proceeds only while SQLite still reports RUNNING with the
same value. If Resume has already advanced the generation, stale bookkeeping,
status/sidecar writes, loop cleanup, and SSE are all skipped. If settlement wins
the lock, its SQLite commit, sidecar write, and loop cleanup finish before Resume
publishes the later RUNNING sidecar, so an older terminal write cannot land last
and stop the replacement worker. Cleanup also captures the terminating loop's
stable id before persistence begins and removes only that id afterward, keeping
the worker identity invariant explicit even for callers outside the action path.
If status persistence fails before the terminal SQLite commit, cancellation
still propagates and the non-terminal loop remains available for a retry after
restart. SQLite commits before the status sidecar and audit write, so a failure
after that commit is distinguished by re-reading SQLite off the event loop and
matching both the observed started_at generation and intended terminal status;
the captured loop is then removed durably before the original error propagates,
preventing a terminal campaign from re-arming after restart.
Lessons: GET /api/lessons, POST /api/lessons (add), DELETE /api/lessons (remove by substring; exact: true narrows the rule match to the whole text, case-insensitive and whitespace-trimmed, for a caller that holds the full rule -- the Memory tab's row Delete -- so "use tabs" does not also take "always use tabs"; a non-boolean exact is a 400 exact_not_bool). A lesson's identity is the pair (rule, repo_scope), so every row the GET returns carries the repo_scope DELETE selector that names exactly that row: "" for a global row (the route's explicit-global selector), the canonical fragment for a scoped row, and null for a row whose stored scope is present but unusable -- both stores keep such a row reachable only through the UNSELECTIVE delete and the route refuses the raw value as a selector, so the client sends none for it. A stored fragment the shared credential redaction chain would alter is reported null too, as is one containing any control character (an escape sequence could split a credential so the chain passes it whole and a renderer reassembles it): the selector must round-trip byte-exact so it cannot be emitted redacted, and it is never emitted raw. On the JSONL tier each row also names the file it was read from (scope: global / workspace, plus workspace for the latter), because the list is a union of the global file and the active workspace's while the delete route defaults to the global file; the client forwards both so a workspace row's delete lands on its own file instead of removing a same-text global row; every workspace row is listed even when a global row shares its text, since the tier tells them apart and a hidden row is one the UI can never delete. The Memory tab renders that value as a Scope column and passes the clicked row's own selector to the DELETE (client.ts deleteLesson(rule, repoScope) sends the key only when it is a string), so deleting what looks like a duplicate row no longer removes its same-rule sibling in another scope (#10651). The Scope cell labels the global row ("Global") and the unusable-scope row ("Unusable scope") rather than leaving either blank, and the unusable-scope row's Delete asks for confirmation first through the shared useConfirm dialog (confirm button restating "Delete in every scope"), because it is the one delete this tab sends unselectively. A rejected DELETE, or a rejected list re-read after a successful one, renders an ErrorNotice above the table titled for the step that failed (no agent hand-off: the Add row may hold a draft) instead of ending in silence. The MCP learn_list and CLI learn list surfaces render the same scope beside each rule ((scope: <fragment>), (scope: unusable), nothing for global) so the three list surfaces and their scope-selective removes agree on a row's identity; learn_list also marks a row read from the active workspace's JSONL file with (workspace: <name>), and learn_remove accepts optional scope / workspace (validated by LEARN_REMOVE_SCHEMA) forwarded to the DELETE body, so the model can name that row instead of the delete defaulting to the global file. The pair is validated together on both the tool and the route: scope='workspace' without a name is refused (400 workspace_required; it would otherwise fall through to the active workspace's or the global file), a name without the tier is refused (workspace_without_scope), an unknown tier is scope_not_allowed, a malformed name is workspace_invalid, and the reserved name default under scope='workspace' is workspace_reserved (it is the global file's name inside _get_lessons, so accepting it would route a workspace-tier delete onto the global rows), and a name absent from the configured workspaces map is workspace_unknown (workspace_dir_for resolves an unmapped name onto the DEFAULT workspace directory, so a typo would delete from that file) -- nothing is deleted on a refused pair. The Memory tab's Scope cell shows a workspace-file row's tier as a second line ("Workspace dashboard/handlers/feedback.py): GET /api/feedback/eligible?userId= and POST /api/feedback/submit are a same-origin proxy in front of AWS Aperture's session-pulse survey (form KiroCrew/SessionFeedback/1.0.1). Aperture's browser-CORS allowlist is a fixed, known set of domains and Kiro Crew is self-hosted with no single canonical domain, so a direct browser→Aperture call is fundamentally unreachable for this product; the backend forwards both calls server-to-server, where CORS does not apply. metadataList order in the POST payload is load-bearing: Aperture's ingestion API validates it positionally against the form template's own registration order, not as an unordered set, so reordering the same keys 400s identically to a wrong key. Eligibility is dual-layer — the frontend's own 30-day localStorage cooldown is checked first (and short-circuits before this endpoint is ever called), then this endpoint asks Aperture's Prompt API for server-side per-user dedup — so either check being stricter wins. A submission failure (network, non-2xx) returns a coded, non-2xx JSON body rather than raising, and the frontend treats a failed submit as retryable rather than a false success.
Tips (feature discovery): GET /api/tips/next (returns {tip, glow} — tip is {id, feature, title, body, why, doc, doc_link, cta_prompt, action?} or null; doc is the tip's DISMISSAL IDENTITY (doc-level dismiss/snooze key on it) while doc_link is a rendering-only "learn more" hint — split so a curated tip can link a catalog-owned doc without its dismissal suppressing the catalog's own tip for that doc (#3524); glow true only when the server-side cadence gate (tips_cadence_hours, default 6h) is open AND ≥1 eligible tip exists; an offered tip persists across polls until feedback; 204 when tips_enabled=false or the user opted out), GET /api/tips/status (returns {enabled_config, opted_out, cadence_hours} — read-only flags for the settings toggle plus the configured cadence, which the client uses to derive its polling gate as min(20min UI floor, cadence) so sub-20-minute cadence configs take effect), POST /api/tips/feedback (body {id, action}, action ∈ shown|ack|dismiss|snooze|helpful|optout|optin; shown is sent by the frontend when a tip is actually displayed — it starts the cadence gate and clears the offered slot WITHOUT dismissing, so a passively-viewed tip is not re-served every turn yet remains eligible for future re-selection; ack/dismiss permanently acknowledge, snooze re-eligible after tips_snooze_hours (48h default); any shown/ack/dismiss/snooze closes the cadence gate; non-object bodies and non-string/oversized fields → 400). Tip catalog is pre-generated at release time via scripts/generate_tips_catalog.py and bundled as src/kiro_crew/data/tips_catalog.json; doc selection is ALLOWLIST-based (kiro_crew/tips_allowlist.py, single source shared by the generator and the runtime scanner) — only user-facing feature docs are eligible, internal architecture/incident docs can never surface as tips. At runtime, get_tips_cache loads a hand-authored, action-first curated tips source (src/kiro_crew/data/tips_curated.json) as the PRIMARY candidate pool, then the bundled catalog, then a live docs/*.md scan (same allowlist) as fallbacks — curated covers shortcut/toggle features native to Kiro Crew (split view, warm pool, MCP gateway, subagent parallelism, zero-token crons, Dev Fleet, App Store, …) that have no doc page and so can never surface from the doc-scan. A curated tip MAY carry an optional action object {kind:"route", label, route}: route is validated server-side to be an internal dashboard path only (must start with a single /, no //, no scheme) by _sanitize_tip_action, threaded through _sanitize_persisted_tip and passed through _redact_tips unchanged (non-string fields are not redacted); TipCard.tsx renders it as a single accent deep-link button (via useNavigate, guarded by a client-side tipActionRoute re-check) that jumps to the exact settings tab/control — with ?highlight= where a control anchor exists — and fires an ack feedback on click. Tips with no in-app destination omit action and render body-only. Personalized generation runs in the _bg session pinned to Haiku (tips_model, default claude-haiku-4.5 via per-session set_model) at most once per 6h (maybe_refresh, task-handle deduped, catalog-only fallback), reads user memory (preferences/projects/recent history) for relevance. Selection uses an explicit exploration blend (tips_explore_ratio, default 0.2, clamped [0,1]): with that probability a tip is picked uniformly at random from all eligible catalog entries (context-independent general features); otherwise the weighted-random newer-biased pick applies. Output is strictly field/type/length-validated, doc and doc_link sanitized to http(s)/.md only, and redacted at point of serving. Parse failures log length only (never raw LLM output). Per-user state (acknowledged, snoozed, offered, last_shown_ts, opt-out) in ~/.kiro/crew/tips_state.json (atomic writes). Implementation: tips.py; frontend TipCard.tsx renders a single-line ambient strip above the chat input during running turns, yielding to the queued-message stack / question card. Users can disable tips entirely via the "Feature Tips" toggle in Settings → Chat (ChatPanel.tsx), which calls the optout/optin feedback actions with an empty id; the toggle reads its state from /api/tips/status and renders disabled with a hint when tips_enabled=false at the config level.
Feature videos (deterministic feature intros): GET /api/feature-videos/next (returns {video, enabled} — video is {id, feature, title, description, src, poster, duration_s, doc, min_version} or null, and src is always a same-origin path because every clip offered is on this machine; there is no source field and no download_enabled here — a source could only ever say local, and whether bytes may be pulled decides what the background pass lands, never what this route offers, so the frontend's optional FeatureVideo.source and FeatureVideoNext.download_enabled read as absent, which their types define as local and off), GET /api/feature-videos/status (returns {enabled, download_enabled, release, cached, total, downloading, download_state, state} — state keeps its original meaning, the whole id -> {status, ts} engagement map for the settings panel, and the transfer's own step is the separate download_state; renaming state would have been the smaller diff and the larger break; download_enabled here is the ceiling's answer through the 60s memo download_permitted_cached, since the panel polls this route while a transfer runs and an audited SEL row per poll would bury the rows that record a real decision), POST /api/feature-videos/fetch-all (starts the cache-fill pass now with the rate limit lifted, 403 governance_denied when the ceiling forbids it), POST /api/feature-videos/feedback (body {id, status}, status ∈ seen|dismissed; both are PERMANENT — there is no snooze, because a feature intro that comes back is noise — and an id outside the catalog is a coded 400 unknown_video rather than a new state key, so the state file cannot accumulate unbounded client-supplied keys). Implementation feature_videos.py, routes registered beside the tips routes in dashboard/routes/realtime.py. Deliberately NOT built like tips: a video is a RECORDED artifact, so nothing about it can be generated at request time and the rule set is deterministic — the catalog is a static tuple in the module (no doc scan, no LLM), and the catalog is DATA either way — a signed manifest published per release (feature_videos_manifest.py) with that static tuple as the fallback for an install that has never fetched one. ELIGIBILITY is deterministic; WHICH of several equally-eligible clips is shown is a uniform random draw (feature_videos._rng), which replaced the original first-in-catalog-order rule because order stopped being a statement about the user: with a growing hosted library, catalog order is publication order, so the first entry would always win and the rest would only be reachable by retiring the ones ahead of it. Only clips ON DISK are offered — a bundled asset or a hosted clip the download pass has landed and sha256-verified — so playback costs no egress and no spinner, and the browser is never handed a CDN url: a remote src would play bytes the pin never checked and follow redirects the gateway's own opener refuses, so an uncached hosted clip waits for the next launch. A verified manifest REPLACES the static catalog rather than extending it: mixing them would offer a bundled clip and its hosted successor as two videos, and a user retiring one would still be shown the other — so on the launch where a manifest has arrived but none of its clips has landed yet, nothing is offered; that one quiet launch is the stated trade, and there is no fall-back to the bundled set while a manifest is in force. Media lands in ~/.kiro/crew/feature-videos/<release>/ (owner-only) through a boot-time background pass that is serialized, sha256-verified, rate-limited to 512 KiB/s, resumable across restarts and idempotent (a verified install leaves a .<name>.sha256 receipt beside the file, and is_cached requires presence, the declared clip size AND a receipt equal to the pin the manifest in force carries — so a re-published entry that keeps its basename but changes its bytes, or any re-encoded poster, is fetched again rather than served stale for the life of the release; a file with no receipt, another process's or one whose install crashed before the receipt landed, is never counted as cached), evicting whole release folders oldest-first to fit dashboard.feature_videos_cache_max_mb and never evicting the running release; the cache is read back by GET /feature-videos/<release>/<file>, a dynamic route (never add_static, never a listing) that validates both URL components against the same rules the manifest applies — including the suffix rule: only a name the parser could have admitted for a clip (.mp4) or a poster (.jpg/.jpeg/.png/.webp) is served, with the Content-Type taken from a fixed table keyed by that suffix and X-Content-Type-Options: nosniff, so a file another local process planted in the (user-writable) release folder under any other name is a 404 and nothing this route answers is a type the browser executes on the dashboard's origin — and anchors containment to the canonical cache root so a symlink inside the cache cannot point out of it; the check is then settled on the DESCRIPTOR rather than the name — the route opens once with no-follow semantics and requires the kernel-reported path of the open file (pinned_fs.fd_real_path) to be the canonical <root>/<release>/<name> and its link count to be 1, so a release folder or the root swapped for a link after the by-name check is a 404 and a hard link into the cache is not served. The write side holds the same invariant: every write under the cache (both transfers, the install rename, the receipts, the manifest store) happens inside feature_videos_manifest.pinned_release_dir, which holds the release folder open (asset_downloader.pin_target_dir — descriptor-relative operations on POSIX, a rename-blocking handle on Windows), proves the descriptor's real path is <canonical root>/<release> and refuses otherwise, and hands the downloader a PinnedTargetDir so staging, install and lockdown never re-resolve the folder's name; the install compares the installed name's inode to the descriptor the bytes were hashed through and unlinks anything else. The delete side is the same shape: eviction removes a release folder through pinned_fs.remove_tree_pinned, approving the removal only when the open directory's real path is <canonical root>/<release> (Windows, with no descriptor-relative walk, holds the folder open — a handle that blocks renames above it — proves it the same way, empties it under that hold and rmdirs last). The cached manifest is read back through pinned_fs.read_file_pinned, no-follow and capped at the same 1 MiB the network fetch enforces, and every link check under the cache refuses a Windows junction as it refuses a symlink (platform_compat.is_link_or_junction). Fetching any of it is governed by capabilities.feature_videos_download, fail-closed — see governance.md. An entry is eligible when the dashboard.feature_videos_enabled kill switch is on, no seen/dismissed status is recorded for it, min_version is satisfied by the running version (a floor that cannot be parsed fails CLOSED; a running version that cannot be parsed passes, so an unreadable version string does not withdraw every floored clip), and no used_when probe fires. used_when names deterministic probes evaluated LAST and lazily, at most once per /next request and only for entries no cheaper check already ruled out: tips_feedback_exists (any reaction recorded in tips_state.json), artifacts_nonempty (a scandir that stops at the first artifact directory, not ArtifactStore.list(), which would read every meta.json on a polled route), sel_event_seen:<tool> (a bounded tail-first sel().recent(limit=…) scan) and config_key_set:<dotted.path> (presence in config.json / config.local.json, NOT the effective config — every effective key has a value, so an effective read would fire on the shipped default and withdraw the clip from someone who never touched the setting). ANY firing signal withdraws the video, since an intro for a feature already in use is the one thing a feature intro must not do. A probe that raises and a signal nobody registered both count as "not used" and are logged — the failure mode is one clip a user may not need, where the opposite default would silently withhold every intro on a host whose audit log or artifact directory is unreadable. src/poster are same-origin relative paths under /app-assets/feature-videos/ validated by one function, validate_asset_path, which refuses any : (so every scheme, plus a Windows drive letter), // (protocol-relative and empty segments), .., % (so a percent-encoded traversal cannot reconstitute one after the browser decodes it), a backslash, whitespace/control characters, and anything outside the prefix — a clip src is fetched by the browser with the dashboard's own credentials, so an off-origin value there is an outbound request the user authorized without knowing it; the same function is also the one gate for the other source shape, a cached clip under /feature-videos/<release>/ (the second same-origin prefix). There is deliberately no third, off-origin shape. An entry failing validation, or naming a doc outside tips_allowlist.TIP_DOC_ALLOWLIST (the same gate tips use, so a video cannot point at an internal design note), is dropped from the catalog with a logged warning rather than 500ing the endpoints. used_when is withheld from the client payload: it names local state the frontend has no business reading, and shipping it would invite a client to re-evaluate eligibility and drift from the server's answer. A temporary or incognito session gets {"video": null, "enabled": true} via _is_restricted_session — the recorded state is permanent and instance-wide, and a session opened to leave no trace must not write it. The kill switch answers {"video": null, "enabled": false} rather than a 204, because the settings panel and the modal both have to tell "the operator turned this off" apart from "nothing left to show" and a bodiless response cannot. State is ~/.kiro/crew/feature_videos_state.json beside tips_state.json, written through atomic_write(restrict_to_owner=True, restrict_on_error="warn") — the file records which features this user has engaged with, which is a behavioural profile. Per-entry validation on load (not just a root type check) so a hand-edited file cannot 500 every endpoint. User-facing doc: feature-videos.md.
Prompts (CRUD): GET /api/prompts (list; user prompts from ~/.kiro/prompts/ and <project>/.kiro/prompts/ plus read-only package SOPs), GET /api/prompts/{name}?scope=global|local (scoped read), POST /api/prompts (create; body {name, content, scope}), PUT/DELETE /api/prompts/{name}?scope= (update/delete; PUT body {content, base_hash}). All five resolve <project> — the local/"This project" scope — through one seam, handlers/prompts._prompt_local_project, so list, scoped read, create, update and delete cannot disagree about which checkout "local" names: a prompt created there is one the same request lists, and the bytes a scoped read seeds the editor from are the bytes that scope's PUT replaces. It is the request's own project, not the process-wide KIROCREW_PROJECT_DIR: that resolver is detected from repository markers, so it names the Kiro Crew source tree on a git install and nothing at all on a wheel, and a prompt authored in the user's own checkout is under neither. Both halves of the prompt surface resolve from the requester for that reason — chat_runner._expand_prompt_mention from its own slot, these five from the request — so a prompt the chat can match is one this surface can list and write. Which question a dashboard request asks depends on whether it names a real chat. A request whose X-Session-Key names an existing slot speaks for that chat and resolves strictly per-slot (requesting_slot_project) — the same answer chat_runner reaches from slot.project when it expands an @mention, so the scope offers exactly the prompts that chat can match, and a chat with no project of its own is told so rather than shown a neighbouring chat's checkout. A request that names no slot is a global surface and gets active_project_dir's fallback, the single project every open slot shares; that step is what gives the overview Prompts tab and the command palette an answer at all, since they sit outside any chat and have no slot to name. The dashboard:ui placeholder counts as naming no slot: the browser sends that literal whenever it has no chat to name — including this API's create, update and delete, while the listing GET sends no header at all — and the slot-name split would otherwise turn it into ui, a name a user's own chat can carry, so honouring it would point every settings-page write at that chat's checkout while the listing answered from the shared project. A chat genuinely named ui sends the same bytes (the real key is dashboard:<slot>), so it too resolves as the slotless surface — the fail-safe side of an ambiguity the wire cannot settle. Two chats on different projects resolve to none, and a local write then answers no_active_project rather than guessing a checkout. An app-token request gets neither the shared-project fallback nor a foreign slot: grants are path-only, so an app permitted to read /api/prompts could otherwise forge an X-Session-Key and use another slot's project as a prompt-content oracle. It resolves strictly per-slot and only for a slot it owns, and any other case narrows to no local project — the app keeps the package SOPs and global prompts its grant covers, gains no local ones. Both outcomes of that selection are SEL-audited under app_isolation with operation=prompt_local_project — an allowed event names the slot an app was actually served, a denied one names the slot it was refused — so an app request leaves exactly one attributable line either way and a compromised app's whole reach is reconstructible from grants, not inferred from the absence of refusals. Every user-prompt listing entry — from the directory scan and from the exact-name lookup alike — is minted by one gate, handlers._prompt_dir_entry: a *.md that is itself a link or junction, or that resolves outside its own prompt directory, onto an is_sensitive_path target, onto a hardlinked or non-regular inode, or whose stem fails _plain_stem_ok (the single predicate create, the scoped read and both write verbs address a prompt by — so the local listing offers exactly the names this API can open, edit or delete), is dropped from the listing rather than merely described — a project's .kiro/prompts is content the user CLONED, so a repository shipping creds.md -> ~/.aws/credentials must not get its target's first heading published as a description nor become @mention-able. The link refusal is platform_compat.is_link_or_junction (lstat-based, so nothing is dereferenced to reach the verdict, and a Windows junction is covered) and it is unconditional — the same predicate the scoped read and both write verbs already apply, so an entry the listing kept because the link happened to stay inside the directory would have named a file no other verb on this API will open. Containment is compared resolved-to-resolved, so an ancestor link the user chose (a dotfile-managed ~/.kiro) still works. That entry-level check says nothing about the prompt ROOT — when the root itself is a link, both sides resolve into its destination and every entry inside looks confined — so the local scope's two enumerating callers gate the root first AND PIN it, through prompts._local_prompt_scan_root: it runs the same prompts._resolve_prompt_dir the scoped read and both write verbs use — _list_aim_prompts skips the local scan and _local_prompt_entry returns a miss when that answers linked_prompt_root — and then hands every downstream containment check the root RESOLVED ONCE rather than the name. That distinction is what a directory swap turns on: _prompt_dir_entry's parent comparison and the within_root its description read is pinned inside both used to re-resolve the caller-addressed root, so a root replaced by a link AFTER the gate ran resolved into the link's destination on both sides of every later comparison and every file under the directory the swap named looked confined — published with a description and injectable by @<stem>. Compared against a value resolved before the swap they are all refused, and a swap landing EARLIER makes the pinned value escape the project, which the same gate catches. So the local library goes empty under an active swap, the answer a statically redirected root already gives. Pinning a DESCRIPTOR would additionally keep the honest entries listed through a swap, but dir_fd enumeration does not exist on Windows, so it would buy availability on one platform while the refusal is what the security property needs. The global root is pinned for the duration of its scan too, which costs that scope nothing and denies a swap landing mid-scan. Without that, a cloned repository shipping .kiro/prompts -> ~/Documents (or .kiro -> ~) would get every *.md under the directory IT named published with a filename and first-heading description, and @<stem> would inject up to MAX_PROMPT_BYTES of one, while every serving verb answered linked_prompt_root for the same name; is_sensitive_path does not cover this, since it is applied to the resolved entry and an ordinary $HOME document is not sensitive. The GLOBAL root is deliberately not symmetric — the global scoped read refuses a symlinked ~/.kiro/prompts while the listing still walks it — because that directory is a location the operator chose rather than one a checkout can name, and refusing it would withdraw the whole global library from anyone who stows it. A refusal to name one file must never become an error for the library around it, so every filesystem call in the gate is wrapped and RuntimeError is caught alongside OSError/ValueError: a cloned project shipping loop.md -> loop.md is refused by the lstat before anything dereferences it, but Path.resolve() signals a symlink loop with RuntimeError rather than an OSError, so an entry swapped for a loop between that lstat and the resolve would otherwise take the listing down with a 500 instead of losing one entry. The ROOT gate catches it for the same reason and answers linked_prompt_root, and there the loop needs no race to arrive: _linked_prompt_root asks os.path.islink, which swallows the ELOOP and answers False, so a checkout shipping a cyclic .kiro reaches _local_prompt_dir_in_project's resolve undetected — and that gate runs outside any broad catch on both enumerating callers, so an escaping RuntimeError is a 500 on GET /api/prompts and on the unscoped detail lookup rather than one empty local library. A loop names no directory inside the project, so it is refused like any other escaping chain. chat_runner's @mention reads the path hooks.validate_file_path canonicalized rather than the name as addressed, so the sensitive-path check and the read cannot name different files, and it reads it through hooks.safe_read_file_bytes_nolink — the same gate the scoped read uses — rather than by name: canonicalizing and then opening that name still leaves a window in which the leaf is swapped, so the gate opens first with O_NOFOLLOW and validates the descriptor it actually read, which makes the inode checked the inode injected into the turn and refuses a hardlinked or non-regular one (st_nlink > 1 is the only signal a second NAME for a sensitive inode leaves, and it is readable only on the descriptor). The gate is given within_root only when the entry positively names the local or global user scope, whose root the listing gate already validated it against — that pins the opened inode inside the prompt tree, so an ancestor directory swapped for a link cannot redirect the read out of it, while a package SOP (whose roots are plural and come from the platform seam) keeps the containment its own provider gave it instead of one this path guessed. A refusal is reported as the same not_found an unreadable file already produced, so it reveals nothing a link's target could be probed with. The unscoped GET /api/prompts/{name} likewise does all of its filesystem work in ONE executor job — _find_prompt, hooks.validate_file_path and the body read share _resolve_and_read, the shape _api_user_prompt_detail's _read already has. Offloading only the resolution and finishing on the loop was survivable while a match could name nothing but a package root or the gateway's own ~/.kiro/prompts; a match can now name <project>/.kiro/prompts, so a stat and a read left behind would stall every other request and the heartbeat on exactly the storage this route newly reaches. Only _prompt_local_project stays on the loop, and it reads state._slots and nothing else. Every reader of a prompt entry reads through hooks.safe_read_file_bytes_nolink, not by name, and that set is the whole set: the chat @mention expansion, the scoped read, the unscoped detail read, and the LISTING's own description read. A user-prompt entry describes itself through _gated_sop_description, whose within_root is the PINNED prompt root; _extract_sop_description keeps the package SOP walk, where the roots are plural and come from the platform seam so the canonical path's own parent is the only root available. The minting gate refuses a link and a hardlink by lstat, so what a by-name read still lost was the window BETWEEN that lstat and the open: the path is re-resolved there, so an entry swapped in between served its target's bytes — a whole file through the detail read, and one heading or description: value through the listing. The root each read is pinned inside comes from one shared derivation, _prompt_read_within_root over _prompt_read_root, because a root derived per reader is how two readers of one directory drift apart. For an entry positively naming the local or global scope it re-runs that scope's own root gate rather than assembling <project>/.kiro/prompts at the call site, which closes the window from the MINT to the read: safe_read_file_bytes_nolink realpaths whatever within_root it is handed, so a read confined to a root nothing had checked since the entry was minted would have that realpath follow a root swapped for a link into the link's destination on both sides of the comparison — an outside file carrying the matched prompt's own name then passes the check and is what @mention injects and what the unscoped read returns in full. None for a user-scope entry therefore means REFUSE rather than "unconstrained", because that is the state a swapped root leaves and a fallback there would pin the read inside exactly the directory the swap named; the @mention reports its ordinary miss and the unscoped read its file not readable 500. Known residual, recorded rather than implied: within_root is a path and the gate realpaths it at read time, so a root swapped between the derivation and that realpath is still followed — two adjacent syscalls rather than a whole scan, but not closed. Closing it needs the read performed RELATIVE to a held directory descriptor (pinned_fs.open_dir_pinned, which the write verbs already use), which is a safe_read_file_bytes_nolink contract change shared with its other ~40 consumers — the scoped read and the package-SOP description read on this same surface carry the identical residual — plus a decision about the name-based fallback on Windows, where _DIR_FD_SUPPORTED is False. It belongs in one change that moves every reader. A package SOP or an unfamiliar entry shape takes the fallback instead — the canonical path's own parent, not a weaker authorization but the only one available across plural seam-supplied roots, and the one thing that carries the guarantee onto Windows, where O_NOFOLLOW does not exist and only the fd-real-path check still sees that the inode opened is not the one resolved. The unscoped read's refusals collapse onto the file not readable 500 it already answered for an unreadable file, because the gate reads and refuses through one descriptor and so cannot say which of a link, a hardlink, an escaping inode or a bad mode happened — deliberately, since separating them would make the endpoint an oracle for a link's target; the sensitive-path refusal is checked before the read and keeps its own access denied 403. Expansion's filesystem half runs OFF the event loop — both call sites go through _expand_prompt_mention_off_loop, which hands _resolve_prompt_mention to asyncio.to_thread, the same treatment the $skill expansion beside it gets — because that half resolves and reads files in a directory the gateway does not own and that may be network-backed, and the local half is uncacheable, so the cost cannot be amortized away and one @mention on slow storage would stall every other request. The split is drawn by which THREAD may do the work, so it cuts both ways: _resolve_prompt_mention takes the message and a project directory and is handed neither the slot nor the state, and the "Loaded prompt" chip it returns is appended by _surface_prompt_chip back on the loop, after the await. slot.append ends in slot.event.set() on an asyncio.Event, whose waiters are resolved through the loop's call_soon rather than its threadsafe variant, so appending from the worker would queue a callback the loop is never woken for — and raise outright under asyncio debug mode, failing the turn. _expand_prompt_mention remains as the synchronous on-loop entry point for callers already on that thread. _find_prompt still resolves that local half by exact stem against the _prompt_local_project-supplied project (_local_prompt_entry) instead of listing it: the worker thread is per turn, so listing there would still pay a description READ per prompt in the directory on every turn beginning with @. The candidate is taken from the directory's own entry name (os.scandir, one getdents, no per-entry stat) rather than by joining the caller's string onto the prompt root — the two spellings name the same inode, since _plain_stem_ok runs first, but only the enumerated form makes that unconditional rather than a property of the predicate — so the per-turn cost is O(1) in the number of prompts the directory holds rather than linear in it: the root gate's lstat and two resolves, one getdents, the entry gate's stats, and on a HIT one description read of the matched file (which _resolve_prompt_mention then reads again for the body it substitutes, so a resolved mention opens that one file twice and a miss opens nothing). The project-independent half still wins a stem collision, exactly as when both halves were one list. Every mutation (POST/PUT/DELETE) is dashboard-user-only: the middleware's app-token grants are path-only (verb-blind), so the handlers refuse a non-empty — or absent — app claim with a SEL-audited coded 403 app_token_forbidden before parsing any body. Writes address their target by explicit scope plus file stem, never by list-order resolution, so a stem present in both user directories can never be written through the wrong one; package SOPs live outside both user directories, so editing one is unrepresentable rather than refused (a write naming one answers a coded 404 and leaves the package file untouched). Each write invalidates the 5s list cache so a created prompt appears immediately, and every outcome — including every refusal, since a refused write can be filesystem probing — is SEL-audited under the same code the response carries. The scoped read reports two transformations separately: redacted when credential/exfiltration filtering altered the copy, and lossy when the bytes are not valid UTF-8 and were decoded with replacements. Editing is refused for either, because both serve a copy that is not the file and saving it would write the transformation over intact bytes. The scoped read also returns hash (sha256 of the raw file bytes), and PUT requires it back as base_hash: the writer verifies it through hooks.verified_replace_file_nolink, a compare-and-swap that hashes the CURRENT bytes off the same opened descriptor whose replacement it stages — verification and replacement share one name resolution — and answers a coded 409 content_conflict when the file no longer matches, so an edit started before someone else’s save cannot silently discard their work. A concurrency change landing after verification (an external atomic save swapping the inode, or an in-place rewrite visible through mtime/size) is detected by pre-rename re-checks — resolved through the pinned directory descriptor where the platform supports it, by name elsewhere — and also answers 409: the newer file wins, never the stale edit. (A swap caught earlier, by the ancestor-link guard at staging setup, stays a 403: that predicate is a security refusal, not benign concurrency.) A PUT without a well-formed base_hash is a coded 400 — an edit that cannot name its base was seeded from something other than the file. A symlinked or junction entry is refused before anything follows it, with the same 403 access_denied whether the link's target exists or not: answering 404 for a dangling link and 403 for a live one would make the endpoint an existence oracle for arbitrary paths. Write safety: the prompt directory is refused outright when its leaf is a link or junction (platform_compat.is_link_or_junction); create and delete then walk <base>/.kiro/prompts component by component, opening each relative to its parent's descriptor and applying O_NOFOLLOW at the leaf only, so a swap after the check cannot redirect the operation while an ancestor link the user chose (a symlinked ~/.kiro) is still followed — the same rule the leaf check already sets; update goes through hooks.verified_replace_file_nolink (sharing hooks.safe_write_file_nolink's engine), which carries the source inode's access-control xattrs onto the replacement. Platforms without openat/unlinkat/mkdirat keep the by-name path and the narrower leaf check.
Skills (CRUD): GET /api/skills, POST /api/skills (create), GET/PUT/DELETE /api/skills/{name} — the mutating verbs (POST create, PUT, DELETE) are owner-only via the same is_owner_dashboard_request guard the skill-trust endpoints use (_deny_non_owner_skill_operation), refusing app tokens and non-owner subjects with a SEL-audited 403 before any write, and every write outcome (ok/rejected) emits a SEL log_tool_invocation record: skill content is injected into agent context, so a skill write is an instruction-injection surface (#7344). The same gate fronts the rest of the mutating skill-handler family in prompts.py — pending approve (POST /api/skills/-/pending/{slug}/approve, which promotes candidate content into the live catalog), pending dismiss + dismiss-all, pin (POST /api/skills/-/pin), and inject-on-trigger (POST /api/skills/-/inject-on-trigger) — so no prompts.py skill mutation is reachable by an app token or non-owner subject (#7393 first-principles round). POST /api/skills/-/discover/install lives in the separate discover module and carries its own guard (it refuses internal-secret callers so an agent cannot persist a registry skill; it is not owner-gated). GET is deliberately not owner-gated — kiro-workspace/ entries are scoped to the requesting chat slot's project via X-Session-Key (the same scoping steering and /api/agents use); without a key the single project shared by every open slot is used, and with multiple distinct projects open the workspace scope fails closed rather than guessing (#2457). That scoping is read-only: because a kiro-user/ or kiro-workspace/ key resolves per-machine / per-session on read while the writers join it onto the core skills root, PUT/DELETE on such a key answers 405 (Allow: GET, code: readonly_skill_prefix) and a create whose sanitized name lands in either territory answers 400 (code: reserved_skill_prefix) rather than editing a file the reader was never shown. GET /api/skills also accepts ?agent=<name>, which scopes the listing to that agent's own skill:// mapping (the same globs agent_skill_globs resolves for the prompt-injection path) by keeping only skills whose loaded_by_agents includes it; an agent with no explicit mapping — empty globs — keeps the unfiltered listing, so an agent that never customized its skill set does not lose access just because a different, customized agent exists on the same install (#3348). When the agent filter is actually applied (non-empty globs), the response is the envelope {"skills": [...], "agent_scoped": true, "agent": <name>} rather than the bare array — a filtered list, especially an empty one, is otherwise byte-identical to the legacy shape, and the picker needs the flag to cue the scope ("Scoped to agent …") and to attribute an empty result to the mapping ("No skills mapped to …") rather than to the catalog (#6028); every unscoped path keeps the bare-array shape. The $-token expansion path (resolve_dollar_skills) is NOT agent-scoped, so a skill hidden from a scoped picker still expands if its $leaf is typed by hand
MCP Servers: GET /api/mcp (list with enabled/disabledTools state from ~/.kiro/settings/mcp.json), GET /api/mcp/active (per-agent MCP servers — reads from agent config for non-kirocrew agents, global mcp.json for kirocrew), GET /api/mcp/probe (cached probe results, non-blocking, 10-min TTL, preserves enabled state), POST /api/mcp/probe (live probe all, merges enabled/disabledTools from global config), POST /api/mcp/sync (discover + add to both kirocrew.json AND global mcp.json + session reset), POST /api/mcp/toggle (enable/disable server in global mcp.json + sync tools/allowedTools to kirocrew.json), POST /api/mcp/toggle-tool (enable/disable specific tool via disabledTools in global mcp.json), POST /api/mcp/toggle-all (bulk enable/disable all servers in global mcp.json + sync to kirocrew.json)
Prompt and package-skill read safety — the descriptor gate: Three content reads on this surface that are not the scoped prompt read go through hooks.safe_read_file_bytes_nolink as well, rather than re-opening a validated name. They are the listing's per-entry description (prompts._extract_sop_description for every package SOP behind GET /api/prompts, and prompts._gated_sop_description for every user prompt — the same gate, differing only in the root each read is pinned inside), the UNSCOPED GET /api/prompts/{name} — the branch with no ?scope=, which resolves across the package SOP roots and both user scopes — and the package/ branch of GET /api/skills/{name}. Each arrives holding a path something above it already judged, and a decision about a NAME does not describe the file a later open by that name returns. A HARDLINK breaks the equivalence with no race at all: it shares its target's inode, so realpath yields the alias's own innocent path, is_symlink() is False and is_sensitive_path sees an ordinary file sitting inside the directory it was found in, while the bytes belong to whatever it aliases. That is decisive for the prompt local scope, whose .kiro/prompts holds content the user CLONED: an alias of ~/.aws/credentials planted there would have that file's first # comment line published as a prompt description and its whole body returned by the unscoped read — a file the agent's own read gate refuses outright. The skills path needs a weaker actor (write access to a capability-seam package skill root rather than to a checkout) but is the same defect. st_nlink is the only signal a second name for a protected inode leaves and it is readable only on a descriptor, so the gate opens FIRST with O_NOFOLLOW, then fstats that one descriptor and refuses st_nlink > 1, a non-regular inode, and an is_sensitive_path target; the inode validated is the inode served. Each passes within_root as the canonical path's own parent wherever no authorizing root exists to pass, and the distinction is worth stating because it looks like an authorization: unlike the scoped read — whose ?scope= names one authorized directory — the package SOP walk and the unscoped read span several seam-supplied roots. A user-prompt entry is the one case that HAS a single authorizing root, so there the pinned prompt root is passed instead, which is strictly stronger: it refuses an inode an ancestor swap redirected out of the prompt tree, which a parent derived from that same swapped resolution cannot see. It is passed because it is the only thing that carries the guarantee onto Windows, where O_NOFOLLOW does not exist at all and the gate's getattr for it yields 0: there the open FOLLOWS a leaf swapped for a link after canonicalization, and only the fd-real-path check (GetFinalPathNameByHandleW) still sees that the inode opened is not the one resolved. A link the entry legitimately points at is already followed by validate_file_path, so its target's own directory IS the root and only a substitution landing after that resolution escapes it. The refusals are reported differently per surface, on purpose, and in every case as the outcome that surface already produced for a file it could not open — so a refusal is not distinguishable from I/O trouble and the endpoint is no oracle for whether a given path is protected. A refused DESCRIPTION yields an empty description and KEEPS the entry, because whether a prompt exists is its caller's decision and a library that dropped a file because its metadata was refused would hide a name the scoped read still serves; that also keeps an unreadable prompt (a bad mode, a transient error) listed with no description, exactly as the by-name read did. A refused unscoped prompt READ answers the same 500 file not readable an unopenable file already produced, and a refused package-skill read leaves the content unset, which is the existing 404. Every one of those refusals also writes a SEL tool_invocation line (prompts._audit_unread, from both description readers and the unscoped read, best-effort so an audit write cannot fail the response), which is what keeps the identical HTTP answer from also being invisible to the operator: an entry that lists with no description is otherwise byte-identical to a prompt that simply has none, and a 404 is what an uninstalled skill name produces. The audit line records THAT bytes were withheld and never why — blocked for a name validate_file_path refused outright (the one knowable cause, since it precedes any open) and otherwise the surface's own outcome (error, or too_large for the skills read's FileTooLargeError) — because the gate judges and reads through ONE descriptor and returns a bare None, and re-stating the path to separate a refused inode from an ordinary read failure would be another by-name look at the input these reads exist to stop trusting. Recovering the cause honestly needs safe_read_file_bytes_nolink itself to return a reason, which is a contract change shared with its other consumers. The package/ skill read runs under asyncio.to_thread, like the same handler's delete and update verbs: no caller-supplied cap applies to it, so it reads up to the gate's own 50 MB default off storage that can be network-backed, and this handler shares one event loop with every other session's turn. Bounds move with the reads. The unscoped prompt read's MAX_PROMPT_BYTES cap comes off a pre-read stat and onto the gate's own max_bytes, so the size that refuses is the size of the bytes actually read rather than of a separately-stat'd name; FileTooLargeError is not an OSError, so each site catches it explicitly — the prompt read to keep its coded 413, the skills read so the gate's default 50 MB ceiling cannot escape as an unaudited 500 where an unbounded read_text previously succeeded. The description read passes allow_truncate instead of a cap, because a description is frontmatter or a first heading, both at the head of the file, and raising there would turn one oversized file into a 500 for the whole listing.
MCP Discovery (multi-provider, config-first page's Add Server modal): GET /api/mcp/discover?q=&provider=&limit= — concurrent fan-out over registered providers (official = public MCP registry at registry.modelcontextprotocol.io via mcp_providers/official.py, always registered; capability = the edition's CapabilityManager CPP seam, registered only when available()); a query under 2 chars short-circuits to {results: [], providers: [...]} without provider calls (cheap availability probe); all provider-sourced strings pass redact_credentials + redact_exfiltration_urls; results carry installed cross-referenced against KiroCrew scope. GET /api/mcp/discover/detail?provider=&id= — full description plus install_plan preview (the exact mcp.json spec install would write: npm→npx -y pkg@ver, pypi→uvx, oci→docker run -i --rm, remotes→{url}; priority npm>pypi>oci>remote) and required_env (env vars installed as "" placeholders the user must fill). POST /api/mcp/discover/install {provider, id} — official: translates the registry entry to a spec (runtime args precede the package target for npx/uvx and are NEVER emitted for oci — publisher-controlled docker flags like --privileged would dissolve the container boundary, so the sandbox is pinned to run -i --rm <image> <package args>) and writes through the locked _set_kirocrew_entry path (409 on name collision with a different spec, name gated by _is_valid_mcp_name); EVERY fresh official install lands disabled — enabling from the servers table (after reviewing the written spec and filling any env vars) is the informed-consent step, and an identical-spec reinstall never flips the user's enabled state; capability: delegates to CapabilityManager.install_mcp then syncs agent config; both SEL-audited as mcp_discover_install. The browse modal offers Install only from the detail pane, and the button stays disabled until the detail (with its install-plan preview) has loaded — pending or failed fetches never expose an active Install (list rows are status-only, and the Enter shortcut applies the same readiness predicate).
Agent Config: GET/PUT /api/agent/config (read/write ~/.kiro/agents/kirocrew.json, PUT auto-restarts sessions; the PUT — like every mutating verb in the agents handler module (agent CRUD, default-agent write, capability install/uninstall/sync, agent-detail DELETE/PATCH) — is owner-only via is_owner_dashboard_request, refusing app tokens and non-owner subjects with a SEL-audited 403 before the body is parsed: ~/.kiro/agents/ is machine-global, so a write there installs tool grants and MCP server commands every later session executes; test_agents_endpoints_owner_auth.py enumerates the module's mutating routes so the gated set cannot drift)
Crew model pins: POST /api/agents and PUT /api/agents/{name} validate a
supplied model before persisting it. The shared role-model validator rejects a
pin the active session's advertised model set proves unusable; an empty or
unknown set still fails open because entitlement is unknowable. The registry
adds the offline, positive case: a spelling it recognizes from another provider
but that kiro-cli does not serve is rejected with code: "invalid_model" and a
non-prescriptive mapping hint. Empty/auto, valid ACP ids, and unregistered ids
remain accepted. kirocrew doctor audits user- and project-scoped agent specs
through the hardened discovery reader and reports the same positive corrections;
an unreadable scan is reported as unchecked rather than clean.
Crew avatars: a crew's face is derived from its name unless the record's
avatar field (see config.md, Per-crew avatar override) pins one of three
tiers. The ghost and picture tiers are edited in the crew editor's avatar builder (CrewAvatarBuilder.tsx,
reached from the header face, the Overview hub face, and the Avatar row of the
Triggers pane) and both follow the editor's two-step Apply → Save: Apply commits
to the editor draft only, Save changes writes the record. Every face that opens
the builder renders through one component, crew/CrewAvatarButton.tsx (issue
#9103): a real <button> named "Edit avatar" carrying a hover/focus scrim with a
pencil glyph, a persistent corner pencil badge under (hover: none), and an
optional one-time "Edit this avatar" chip (mc-avatar-edit-hint-dismissed,
one flag per origin — dismissed by the first click on any face or chip). The
editor header and the Triggers-pane row also carry an explicit "Edit avatar"
text button. The read-only Crew Members page reaches the editor WITHOUT
becoming a second writer, and QUIETLY, because it is a chat surface (issue
#9425): its faces are plain faces (no scrim, badge, text "Edit avatar" button or
first-run chip — #9116 tried those there and they read as an oversized control
inside a conversation). The edit entry is a small pencil button to the RIGHT of
the member name in the DM-header title row, named "Edit member": invisible at
rest, faded in when the title row is hovered or the button focused (150ms,
reduced-motion honoured), and low-contrast-persistent under (hover: none).
That header carries no rule under it — it shares the transcript's background and
meets it on spacing alone, as ChatPage's session header does.
That pencil and the drawer's "Edit in crew manager" button both navigate to
/capabilities?tab=crews&crew=<name>, a deep link KiroCrewAgentsPage latches
once the roster has loaded (open that crew's whole editor; an optional
&avatar=1 opens the builder on top, used by no current caller) and then strips
from the URL, so closing the
editor never re-opens it. Failed thread opens and repairs retain the gateway's
actionable reason in the exact member's cached outcome, including member identity
and database availability errors. A failed repair keeps the confirmed
conversation mounted; its error notice preserves the draft and distinguishes
repair failure from an unopened conversation. Late failures for another member
do not appear in the selected member's thread.
The ghost tier is
pure config — POST /api/agents and PUT /api/agents/{name} accept
avatar: {"kind":"ghost","traits":{…}} (or {} to reset) and reject any other
non-empty shape with 400 invalid_avatar, mirroring session_color. The pack tier is pure config too ({"kind":"pack","id":...}) and is specified below under Crew appearance library. Reactions belong to the tier that can play them (see
config.md): motions (a built-in per-state animation) is the ghost's alone,
because a picture has no face to move and a pack animates from its own files,
and so is sounds (a synthesized per-state preset): a picture is a static,
silent drawing, and a pack ships its own audio files — which this change starts
PLAYING, so the substitute is real rather than promised. CrewStateAvatar reads
the states the worn pack declares a cue for and plays that state's bytes from
GET /api/appearances/{id}/sound/{state} on the same edge, through the same
loadSoundSettings() toggle and volume the presets use; a state the pack does
not declare is SILENT and deliberately does not fall back to a preset, because
the pack's author chose which moments make a sound. A preset on a pack record
would be a second, competing answer to the same question. Both are
authored on the builder's Reactions tab, which is offered on the ghost tier
alone — a tab that renders only a note saying the tier cannot use it is a promise
the tier cannot keep. A ghost's motion vocabulary is CLOSED and mirrors the
backend's own (done: none/bounce/nod/sparkle; error:
none/shake/cross-eyes/droop), and a state the record says nothing about
plays the default — bounce on done, shake on error — so the layer is on for
an uncustomized crew and none is how one opts out. A reaction may move the
ghost, swap its EYES and add a transient glint on the tile; every other axis is
identity and is untouched, which is what keeps a reacting crew the same crew.
expressions (per-state eyes/mouth) is RETIRED: no surface authors it and no
renderer draws it, and the backend validator drops it in the follow-up change.
A motions pick named on a
picture or a pack payload is stripped rather than refused, so a version-skewed or
hand-written record never costs the crew its face. The
picture tier adds two owner-only, SEL-audited routes and a staging protocol
whose invariant is that no request other than a successful config save changes
what the roster serves: POST /api/agents/{name}/avatar (multipart, read into
memory under a 1 MB cap → 413 avatar_too_large; format decided by magic bytes,
PNG/JPEG/WEBP only, client Content-Type ignored; a body whose container is not
closed — no PNG IEND, no JPEG FFD9, RIFF length ≠ body — is refused 400
avatar_bad_format so a truncated upload can never be promoted; malformed
multipart → 400 invalid_multipart; unknown crew → 404 agent_not_found) only
stages the bytes at <data home>/run/avatars/<sha256(name)>.pending.<ext>
(display names never touch a path) and returns a one-shot staging token. PUT
/api/agents/{name} with avatar: {"kind":"image","promote":true,"token":…} is
the single commit point: under the config lock the staged file is installed at a
content-addressed path (<stem>.<16-hex digest>.<ext>, so an install can
never overwrite the committed file), the record is saved as
{"kind":"image","v":<mtime_ns>,"file":"<digest>.<ext>"}, and only then are the
previous variants reaped; a save that raises rolls the fresh install back and
leaves the prior pin live. promote and token are wire-only and never
persisted. A PUT carrying a plain {"kind":"image"} keeps the committed picture
and discards any stale staging (an abandoned pick must not survive into a later
save); a promote whose token no longer matches the staged file fails the save
with 400 avatar_file_missing rather than silently keeping the old picture, and
so does a promote with nothing staged. Leaving the picture tier through the
same PUT ({} or a ghost override) reaps the stored files after the config
write succeeds, and DELETE /api/agents/{name} reaps live and staged files
inside the same lock. GET /api/agents/{name}/avatar serves only the file
the record's file pin selects and only while the record says kind: image
(a leftover file the config no longer selects is refused), with a content-hash
ETag + 304; the frontend appends ?v= from the record so a replaced picture is
re-fetched without waiting out the browser cache, and falls back to the seeded
ghost when the image fails to load. All avatar filesystem work runs off the event
loop (_drained_to_thread). The image-tier create path is deliberately closed:
POST /api/agents with {"kind":"image"} is 400 avatar_file_missing, since a
crew that does not exist yet cannot have staged a picture.
Crew appearance library: a third avatar tier — avatar: {"kind":"pack","id":...} — dresses a crew in an appearance pack from a library the DASHBOARD owns. It is a separate library from Crew Companion's. That app is independent: it keeps its own packs under its own data directory behind its own enabled-gated /api/apps/crew-companion/appearances* routes, and nothing on the crew side reads, moves or gates them — the two share only the pack format and the store class (appearance_packs/store.py::AppearanceStore), so a pack exported from the Companion gallery imports into the crew library unchanged through POST /api/appearances/import. dashboard/appearances.py owns a lazily-built process-wide AppearanceStore rooted at <data home>/appearance-library/ — a leaf on sandbox._CREW_HIDDEN_LEAVES AND on _CREW_PRECREATE_HIDDEN_DIR_LEAVES, so a sandboxed agent subprocess cannot see or delete the user's packs, and on security._CREW_SECRET_LEAVES, so the agent FILE tools (is_sensitive_path) refuse it on every OS — the bind-mask covers the Linux shell plane only — pre-created empty before every namespace spawn because the mask loop is guarded on isdir and the store otherwise builds its root on first use, which would leave the library visible to every sandbox already running; only the gateway's owner-gated routes touch it — behind a threading.Lock (the shape artifacts.get_default_store already uses), resolved per call through data_home() so a pod or a test never reaches the real install. There is deliberately NO migration and NO shared instance: an earlier design shared one store INSTANCE between the two surfaces and moved the app's packs into it, and every hazard it produced lived at that seam — a migration that could strand a pack, a gallery delete that could blank a crew's face — so the seam was removed rather than hardened. The store CLASS is shared, and lives in core (appearance_packs/): Crew Companion is an optional builtin, so a crew's face would otherwise be drawn by code inside an app the user can disable, and core would import an app. The direction is one-way — an app may import appearance_packs; it never imports an app. Routes live on the dashboard router (the agents slice, after the crew-roster block) because a crew's face must render while the Companion app is disabled or absent. Every route, READS INCLUDED, is owner-gated through the module's _require_owner, which is require_owner_dashboard_request plus the accepted-decision audit the shared gate does not record (it audits the denial and returns None on success, so half a permission decision reached the log); GET /api/agents/{name}/avatar, the nearest sibling, audits its successful reads the same way. All auditing goes through one non-raising _audit chokepoint — an audit describes an operation and must never decide it, so a write that audits after its mutation cannot turn a completed delete into a 500 — and a structural test pins that _sel().log_api_access appears exactly once in the module. Routes: GET /api/appearances → {packs:[...]} built-in first; GET /api/appearances/{id} → pack_detail (404 pack_not_found on a miss OR a malformed id — telling the two apart only hands a caller probing for traversal a signal it does not need); GET /api/appearances/{id}/slot/{slot} serves ONE slot's bytes — read through dashboard/appearances.py::pack_slot_file, ONE manifest read plus ONE file read, never pack_detail, which inlines every file per slot naming it and would make a roster of N crews load N whole packs to draw N frames — with a content-hash ETag, Cache-Control: private, max-age=60 and X-Resolved-Slot naming what was actually served, resolving the fallback chain server-side (working→working|loading|thinking|idle, done→done|idle, error→error|idle; any other name resolves to itself only, because a pack's random clips are named by their author and a fixed vocabulary would make them unfetchable, so "unknown slot" means "not in this pack even after fallback" → 404 slot_not_found) — svg is served as image/svg+xml under Content-Security-Policy: script-src 'none'; style-src 'unsafe-inline', the same policy handlers/files.py puts on an untrusted SVG read, because a pack's SVG is third-party markup on the dashboard's own authenticated origin and navigating to it renders it as a document (the picture tier refuses SVG outright for this reason; a pack's art must be SVG, so it is served inert instead); lottie as application/json; a sprite sheet base64-DECODED to image/png, because this route exists to be an <img src> and base64 text under an image content type is not an image; every media answer also carries X-Content-Type-Options: nosniff, and the headers ride the 304 as well as the 200. The built-in kiro-ghost answers 404 builtin_no_content since its art ships inside the frontend bundle. GET /api/appearances/{id}/sound/{state} is the audio counterpart -- the pack's OWN cue, and now the ONLY cue a pack-wearing crew plays: the crew record's preset sounds is the ghost's, so the two cannot compete for one moment (see config.md). It serves ONE state's bytes with the same content-hash ETag, Cache-Control: private, max-age=60 and nosniff the slot route uses, typed by SNIFFING the decoded bytes (appearance_packs/sounds.py::sniff_audio — ID3/MPEG sync → audio/mpeg, OggS → audio/ogg, RIFF…WAVE → audio/wav) rather than by trusting the manifest's filename, since a manifest is hand-editable and a name is a claim rather than evidence. There is no fallback chain and no idle cue: a cue fires on a TRANSITION, so a state with none is 404 sound_not_found — the same answer a missing pack gives, deliberately — and kiro-ghost answers 404 builtin_no_content. A cue is capped at MAX_SOUND_BYTES (512 KB) decoded, read through the ordinary pack-file read (so it inherits the link refusal, the containment re-check and the per-file ceiling), and one unusable entry is dropped with a warning rather than costing the pack its art. AppearanceStore.save_pack's sounds key is therefore TRI-STATE where art is replace-on-save, and the asymmetry is part of the store's public contract rather than an implementation detail: an ABSENT sounds key preserves whatever cues are on disk, an explicit {} (or a map omitting a state) removes them, and a map replaces them. Absent cannot simply mean delete, because the gallery editor reads pack_detail -- which reports cues as PRESENCE only -- and saves the whole pack back, so every client written before cues existed sends no sounds key at all and would otherwise destroy the user's audio on an ordinary art edit. What makes the implicit path safe rather than merely convenient is the other half of the rule: an overwrite that names no cues but cannot READ a cue the on-disk manifest declares refuses the WHOLE save (pack_sound_payload answers None, from the SAME traversal that builds the carry — the refuse set and the carry payload were once two passes over the same files, and a read that failed only on the second pass produced a carry missing a cue and a refuse set that did not mention it, so the save went through and the cue was gone; reading each file exactly once cannot disagree with itself), because carrying only the readable ones forward would make a transient IO error permanent — pack_detail then reports sounds: {state: true} for the cues that survive, PRESENCE only, because inlining hundreds of KB of base64 audio into the payload the roster fetches to draw a face would make every render pay for cues it may never play; presence and the route read one function, so a state the payload advertises is a state the route can answer. POST /api/appearances/import accepting both the Companion's {"bundle":...} JSON envelope (so the frontend reuses its client code) and a multipart file part read in 64 KB chunks under MAX_BUNDLE_BYTES — the part iteration is guarded too, since a declared boundary that never appears raises there rather than at request.multipart() — delegating every carried-file check to appearance_packs/transfer.py::import_bundle and adding one in front of it, dashboard/appearances.py::bundle_reference_error, run inside the same worker as the write: every file the manifest's states/moods/random maps REFERENCE must be carried, must have a suffix the reader can serve (.svg/.json/.png — .webp/.gif may ride in a bundle but no slot may name them), must decode through the SAME slot_body predicate the slot route serves with (a sprite must be a real PNG, text must be non-blank), states.idle must be present, two carried names that are one file under NFC+casefold (pack_id_key) are refused, each distinct file is decoded once however many slots name it, and the sum over slots of the referenced file's size is bounded by MAX_EXPANDED_SLOT_BYTES — so a 200 never installs a pack whose slots the route then 404s, and no manifest can multiply one large file by its slot count; POST /api/appearances/petdex/fetch delegating to the existing fetch with its host allow-list, HTTPS pin, redirect re-validation and download ceiling untouched; DELETE /api/appearances/{id} refusing the built-in with 400 builtin_pack and refusing 409 pack_in_use (body lists the crew names) while any crew's avatar names that pack, unless ?force=1. That guard is dashboard/appearances.py::delete_pack_if_unworn, and it canonicalizes the pack id ONCE so the wearer lookup and the store cannot disagree — the store normalizes through safe_pack_id while a raw comparison does not, so aurora found no wearer and then deleted aurora. The in-use read and the delete run inside ONE hold of the config file's <config>.json.lock sidecar (loader._config_write_lock), in a worker under _drained_to_thread, nested inside the agents routes' asyncio config lock and the library lock in that order: the asyncio lock serializes sibling dashboard handlers only, while the CLI and other processes write config.json under the sidecar, so a read under the loop lock alone could be answered by a config a CLI avatar write was about to replace and the delete would then remove art a crew wears. Wearer comparison is on appearance_packs.pack_id_key (NFC-normalized then casefolded), because a pack id is a directory name and macOS/Windows fold case while macOS also folds NFC/NFD spellings. Every read route answers 503 library_unavailable when the library root itself is unreadable (OSError out of iterdir), rather than an uncaught 500. A forced delete leaves the crews with a dangling reference that renders the name-derived ghost, which is what an absent pack already means. The picker is the avatar builder's fourth tab, Library (CrewAvatarLibraryTab.tsx, mounted by CrewAvatarBuilder.tsx; the pack format's vocabulary moved into core as lib/appearancePacks/types.ts (the frontend twin of the backend's own move into appearance_packs/) because a crew wears a pack while Crew Companion — an optional app — may be absent, and core never imports from apps/. The slot LABELS stayed in the Companion: core renders no slot name, so moving them would have renamed twelve keys across thirteen catalogs for no reader). It reads GET /api/appearances on every open — import and delete both change the list, and a cached grid would hide a pack the user just installed or offer one that is gone — and draws each card's thumbnail through the PER-SLOT route rather than the detail route, which inlines every file and would load N whole packs to show N frames; the built-in kiro-ghost card composes the seeded ghost locally instead, since the slot route answers 404 builtin_no_content for it. Clicking a card sets the draft to {"kind":"pack","id":...} and the editor's Save writes it — and the draft is cleared again if the pack it names is deleted, so Apply can never persist a pack the library no longer holds; a crew wears any format the library holds. The pack tier renders through components/appearancePacks/PackAvatar.tsx, which reads GET /api/appearances/{id} through React Query (hooks/usePackDetail.ts, key ['appearance-pack', id], staleTime: Infinity — one request per pack per session however many avatars mount it, in-flight sharing included; useInvalidatePackDetail is the write side the Library tab calls after an import or a delete, and because it invalidates a query rather than clearing a module cache, every MOUNTED subscriber re-reads too, so a pack re-imported under the same id redraws on every roster row that wears it. The hook sets no retry of its own: it inherits the shared client's policy in api/queryClient.ts — one retry with backoff, a longer ladder on 429, none on a deadline we set — so a pack read behaves like every other dashboard read when the gateway is throttled or down) and dispatches on the format of the slot the state RESOLVES to: an <img> on the per-slot route for svg, LottieRenderer for lottie, SpriteRenderer on row sprite.rowAssignments[slot] for sprite. The two players live in core (components/appearancePacks/) for the reason the pack format's types do: a crew's face must render while that optional app is absent, and core never imports from apps/ — the Companion reads them from core, Mochi's vendored SpriteRenderer.tsx and LottieRenderer.tsx are one-line re-exports of core's (so the vendored importers' ./SpriteRenderer and ./LottieRenderer paths stay byte-identical to upstream), there is no apps/shared/ copy, a test pins that no core module imports from apps/crew-companion or apps/mochi, and another (lottiePlayerBoundary.test.ts) pins that components/appearancePacks/LottieRenderer.tsx is the ONLY production module that value-imports lottie-web — and imports the light build — because the remote-asset refusal below and the absence of the expression eval() both hold only while every Lottie document goes through that one player (Mochi's own copy was the one unfenced loadAnimation in the tree until it became that re-export). The FORMAT is why the detail route is read at all: it is a property of the slot, so the player has to be chosen before any bytes are requested, which the per-slot route cannot answer. That read inlines every file in the pack, and two of the three tiers never use the bytes (an svg is drawn by an <img> on the slot route, a sheet by a canvas from the same route), so packDetailFrom keeps content only for a lottie slot and stores '' for the rest — the query cache never pins an svg or a base64 sheet no renderer reads from it; the parse cost of the one read per worn pack stands (the shipped sample bundles are 1-4 KB), and a content-free detail variant is the backend follow-up if real packs prove heavier. The two JS players are bounded by VISIBILITY and by STATE, not by size (the svg tier is a plain <img> on the slot route, the same element the picture tier is, and a document inside an <img> is reachable by neither the page stylesheet nor a flag on the component — a pack SVG that animates itself (SMIL) animates as any image does, and bounding it would mean fetching and inlining every svg slot as a document, the cost the <img> was chosen to avoid): each avatar observes its own box through an IntersectionObserver — attached by a ref callback rather than a mount-once effect, because the box is unmounted while the caller's fallback shows and a recovery mounts a fresh span that must be observed too — and holds frame 0 while off screen (autoplay={false} on a Lottie instance, one drawImage and no timers on a sprite), because the dense roster's own avatars are 38px — so any size threshold low enough to animate the crew card would animate every row in a list of dozens at once, which is the cost the bound exists to remove. idle holds frame 0 even on screen: a roster of dozens of looping idles makes motion the wallpaper of the page, so motion is reserved for a REACTION — a turn running, a turn done, an error, or an author-named random clip — which is also what tells the eye something happened. The rule is on the requested state, not the resolved slot: a pack that draws only idle still moves while a turn runs, because the motion says "working" whatever art carries it, and the Library tab's thumbnails (which ask for idle) are stills — and the tab's lib_hint ends by saying so ("Animated ones move while the crew is working"), so a freshly applied Lottie pack sitting still is not read as a broken import. Where no observer exists the avatar animates, since a still face everywhere is a worse regression than an unbounded one on an engine nobody ships. prefers-reduced-motion holds both players on their first frame regardless of visibility, read live through matchMedia because they are JS-driven timelines the stylesheet's global reduced-motion rule cannot reach (through hooks/useReducedMotion.ts, the one live definition — also used by the Issue Radar pipeline view — rather than framer-motion's: that one snapshots the preference into a useState at mount and never re-renders on change, so a preference flipped while the page is open would not pause a running timeline); the pending box wears the dashboard's .skeleton for the retry window so a slow read reads as loading rather than as a blank face. A pack that cannot be read, or that draws nothing for any state even after fallback, reports through onError (on the edge only — once per drawable→broken transition, so a caller re-rendering with a fresh callback is not told again) and draws the caller's fallback IN PLACE, staying mounted: CrewAvatar hands in the seeded ghost — the same thing a deleted pack already shows — rather than latching the pack tier off the way it latches a broken picture, because the pack's query is refetched on focus, reconnect or invalidation only while the component that asked is still mounted to receive the answer; unmounting on failure would make every gateway blip a ghost until the record named a different pack (pinned through CrewAvatar, not the renderer alone, in CrewAvatarPackRecovery.test.tsx). A renderer refusal is reset when the id or the art changes, so a re-import under the same id is tried afresh; malformed art counts as unreadable too, since the importer only checks that a pack's .json is non-empty and that a sheet is a PNG, so LottieRenderer reports a parse failure and SpriteRenderer a sheet that will not decode to the caller rather than only to the console, which had left an empty box the caller believed it had drawn. A failed read is retried per the shared policy before it is reported, and a query left in error state has no data, so React Query refetches it on the next mount, focus or reconnect — which is how a roster recovers from an unreachable-gateway blip without a reload, while a real 404 simply fails again. Valid JSON is not yet a valid clip: {} parses, carries no asset, and then fails inside lottie-web with no event anyone listens to, so LottieRenderer checks the document's shape with the format module's own isValidLottie before loadAnimation, wraps the loadAnimation call itself (the shape check tests key presence, so layers: null passes it and the player throws synchronously, before any listener could be attached — the catch reports through onError like a refusal instead of unmounting the React subtree), and routes the player's own error/data_failed events to onError; a Library card whose renderer reports a failure shows an inline ErrorNotice — "Art won't draw. Use “Import pack”." (lib_art_failed, naming the Import button in the words its own label uses, at the 10px typography floor and sized to wrap on its sentence break in the 72px tile) — in place of the tile rather than the seeded ghost, because on that surface the ghost reads as the pack's own art; it is an ErrorNotice because it reports a render that failed (where pickHint, a validation hint, deliberately is not), with no agent hand-off because the failure is one pack's art inside an unsaved draft and the recovery is the Import button the message names, and re-importing is the one recovery that works (the import path clears the mark). A served sprite config is validated at the read boundary (packDetailFrom → PackSpriteConfig): frameWidth/frameHeight are kept only as integers of at least one (a fraction is dropped, not floored — 16.5 cuts the sheet wrong at 16 and at 17 alike) and fps only as a positive finite number, anything else dropped so the renderer's own default applies — because SpriteRenderer counts frames as naturalWidth / frameWidth, so a manifest's "0" (kept verbatim by the store) is Infinity frames and a trailing-frame scan that never returns, on the main thread. SpriteRenderer refuses the same geometry itself before fetching the sheet and reports through onError, so the Companion's own manifest reader, which also casts the config unvalidated, is covered by the same check; and it refuses a row of more than MAX_FRAMES_PER_ROW (512) frames once the sheet has decoded, because the empty-trailing-frame scan is one synchronous getImageData per candidate frame and its length is naturalWidth / frameWidth — a 1px frame over a wide, cheaply compressed, fully transparent sheet is tens of thousands of readbacks with no early exit. The Library tab's own thumbnails branch on the format for cost, not capability: an svg card stays one per-slot <img> and reads no pack, while a lottie or sprite card draws through PackAvatar, because the slot route serves the first as application/json and the second as the whole sheet, so a bare <img> showed a broken-image glyph or a strip of every frame. Every card carries a radio ring — filled beside "Selected" on the chosen one, empty on the rest: a lone mark read as a label rather than a choice, and a check beside empty rings read as a chip next to unexplained circles (the blind reader rated clicking a card a guess three times), while one filled ring among empty ones is the shape of a radio group, which is what the grid is. The card grid is a fixed-height scroll region, and it fades whichever edge has content past it — a card cut at a hard border reads as a layout bug rather than as a list that scrolls, and the fade is measured after layout because at ref time the grid has no height and an overflowing library showed no fade until the first scroll. A pack brings its own art AND its own audio, so a pack crew gets no reaction picker at all — no motion, and no preset cue either. The dashboard plays a pack's OWN cue: CrewStateAvatar reads which states the worn pack declares one for (sounds presence on the detail answer, parsed by packDetailFrom), and on an edge INTO a state it declares one for — working, done or error, the whole cue vocabulary appearance_packs/sounds.py accepts, so a pack that names a working clip is heard when the turn starts and not only when it ends — plays that state's bytes from the route above through playSoundFile, under the same loadSoundSettings() toggle, volume and per-crew debounce the ghost's presets use. One bound on that, from the read being asynchronous: while the manifest is in flight the hook HOLDS the edge instead of consuming it (packPending), and when the manifest lands it plays the cue for the state the crew is in THEN — the edge it can still see. A state passed through during the read (a short turn whose working became done before the manifest arrived) is not replayed, because two cues for one moment would report the crew twice, and a reaction whose flash dwell has ended gets no late cue. A state the pack declares no cue for is SILENT and does not fall back to a preset: the pack's author chose which moments make a sound, and filling a gap with a synthesized tone would report their pack with a cue they left out. The detail read is issued from CrewStateAvatar rather than from the renderer beneath it, because the cue fires on the transition and that is the component observing it; it is the same query key PackAvatar already uses, so a pack-wearing roster row shares one request, and usePackDetail is DISABLED for a crew that wears no pack so the ghost tier fetches nothing. The built-in kiro-ghost is never asked — it is the seeded ghost, and its cue route answers builtin_no_content like its slot route.
This is why the cue could not ship before the preset retirement and does ship with it. While a pack crew ALSO carried a preset cue, a pack cue would have had to play wherever the record said nothing about a state — and the editor's per-state sound picker deletes the state's key for "No sound" and normalizes a stored `'none'` away on the next Apply, so "the user chose silence" and "the user chose nothing" were one stored value and no client-side rule could honour a user turning a pack cue off. Removing the picker from that tier removes the ambiguity with it: the manifest is the only source, and a pack author who wants a silent moment declares no cue for it. **Importing** takes an exported bundle — one JSON document, `{"kind": "crew-companion-pack", "version": 1, "id": "aurora", "manifest": {"meta": {"id", "name", "author", "description", "format": "svg"}, "states": {"idle": "idle.svg", "working"?, "done"?, "error"?}, "moods"?, "random"?, "sounds"?: {"working"?, "done"?, "error"?: "<name>.mp3|.ogg|.wav"}}, "files": {"idle.svg": "<svg…>", "done.wav": "<base64>"}}`, exactly what the Companion gallery's export produces. A bundle carries the pack's CUES as well as its art, so export → delete → import returns the pack the user had rather than a silent one; audio rides base64-encoded exactly as a sprite sheet does, because the store's write path is text-only. A bundle is also ONE revision of the pack: `export_bundle` composes it from several store reads (`pack_detail` for art, `pack_sound_payload` for cues) and the store holds no lock across a read, while every pack mutation is a whole-directory swap — so it records `AppearanceStore.pack_revision` (device, inode and mtime of the pack directory and its manifest; a swap is a new inode even for byte-identical content) before its first read and compares after its last, re-reading on a mismatch up to `_EXPORT_SNAPSHOT_ATTEMPTS` (3) and refusing a pack that changes on every attempt, because art from one save with cues from another imports cleanly and export → delete → import would then install a pack the user never had. Import judges each carried audio file at the boundary rather than leaving it to the reader's silent drop — it must decode, be at most 512 KB, and SNIFF as one of the three containers. A cue that fails is still INSTALLED, and the import answer names it in a `warnings` list ("is not base64-encoded audio" / "is empty" / "is a longer sound than a pack may carry" / "is not an mp3, ogg or wav"), because the export path writes whatever the manifest declares and our own export must re-import; the import response is the one place the person who picked the file reads the problem. Structural problems still refuse the bundle — a `sounds` value that is not a map, a state naming a file the bundle does not carry, or a name the reader cannot serve (`_sound_reference_problem`) — because those install a pack whose sound route then 404s. The art allowlist (`ALLOWED_SUFFIXES`, still `.json`/`.svg`/`.png`/`.webp`/`.gif`) stayed narrow so `save_sprite_pack` cannot name a sheet `.wav`, and the "a bundle must contain art" refusal now counts ART rather than files: a sound-only bundle installs a pack with nothing to draw, so it is refused with "That bundle has no art in it". The ENVELOPE — `kind`, `version`, `id` — is part of the contract, not packaging: `import_bundle` refuses a payload whose `kind` is not `crew-companion-pack` and takes the pack id from `id`, so `bundleFromText` returns the parsed document rather than a `{manifest, files}` pair rebuilt from the fields this client happens to know. It did the latter once, which type-checked, passed a mocked test, and made every real import fail with this client's own "that file is not a pack bundle" — hence the cross-language test pinning `PACK_BUNDLE_KIND` against the importer that reads it — picked with "Import pack (.json)…", checked client-side only for what is obviously not a bundle (unparseable, no `manifest`/`files`, over the 24 MB total) and then posted as `{"bundle": …}` — three importable sample bundles, one per format, live at `website/src/test/fixtures/appearance-packs/sample-{svg,lottie,sprite}.json`, so every renderer tier can be exercised by hand from this tab; every real rule stays server-side, so the banner shows the server's own message rather than a second copy of the importer's checks, and a success re-reads the list and selects what was installed. The client's id rule is the backend's own (`safe_pack_id`: letters, digits, dash, underscore, capped at 64) and is therefore UNICODE-aware — `packAvatarFrom` matches `\p{L}`/`\p{N}`, trims like the backend and counts code points, because an ASCII-only class read an id the importer really installs (`auróra`, `아우로라`) as "no override" and the next unrelated save wrote `{}` over it. **Deleting** a custom pack takes two clicks on the card and surfaces the 409's crew names verbatim ("In use by: …"); `?force=1` is deliberately not exposed, because a forced delete blanks a face the user cannot see from here. Data-loss note: the crew editor must LOAD a pack override into its draft (`openEdit`, `KiroCrewAgentsPage.tsx`) and compare it in `dirtyPanes` like the other two tiers — while it did not, `avatarPayload`'s `editAvatar ?? {}` wrote "no override" on every save, so editing a pack crew's model undressed it. That wipe belongs to the ENUMERATION rather than to any one tier, so the payload also carries an unrecognised record through verbatim (`unclaimedAvatarFrom`): a tier a newer client wrote, or a pack id this build cannot parse, survives an old client's unrelated save instead of collapsing to `{}`, and the passthrough is dropped the moment the builder commits a draft, which is the user deciding the avatar. **Reset sends `null`, not `{}`**: those are different statements to the backend, whose `_carry_pack_through_faceless_save` (`handlers/agents.py`) answers a faceless save on a pack-wearing crew by KEEPING the pack — correct for the editor that shipped before this picker, which could not see a pack and would otherwise have undressed one on every unrelated save, and wrong for this one, where Reset is a click that means it. `{}` therefore stays the spelling for "this save says nothing about the face" and `null` — which the carve deliberately reserves — is the explicit reset. While the editor sent `{}` for both, Reset → Save reported success and left the crew wearing the pack. The carve can retire once no shipped client sends a faceless save for a pack crew; until then the two spellings are the seam, and both are pinned by tests.
Crew memory-store bindings: explicit member creation provisions an empty member-scoped V2 store. Discovery sync registers ordinary agents with Global V1 and preserves existing bindings. Existing V1 members keep their current memory; editing a member offers no provisioning or migration action. Invalid or mismatched member-store identities produce a named error and never inherit Global V1. See memory for canonical identity and selected knowledge copying.
Chat: POST /api/chat (SSE stream, or JSON with ?ws=1 — chunks via WebSocket), /api/chat/slots (CRUD, POST accepts optional agent field to set agent at creation, plus optional agent_kind (member | template) naming the catalog namespace the agent was picked from — a stated kind is resolved in that namespace only — before the slot is minted, so 409 agent_choice_unavailable (it does not resolve there) registers no slot — 400 invalid_agent_kind for any other value; a template kind pins no member memory store; list responses carry the committed agent_kind, "" for a legacy name-first pick or a restored older slot; list responses include source_links extracted from slot messages with cached provider/number/state/CI metadata), resume from history, POST /api/chat/slots/{slot}/generate-title, POST /api/chat/slots/{slot}/agent (switch agent for slot; body {agent, agent_kind?} with the same agent_kind contract as create, and the response echoes the committed agent_kind), POST /api/chat/slots/{slot}/fork (fork session — copies visible messages into new slot, body: {at_message_index?, prompt?}, returns {ok, key, title, messages, prompt}, new slot has forked_from metadata), POST /api/chat/slots/{slot}/edit-resend (edit a user message and re-run; a real conversation boundary like rewind — discards the native ACP conversation, flushes the cleared resume sid, and persists the truncated window before the live slot adopts the edit. Body: {index?, ts?, content}. Refusals: 400 invalid_content (a present non-string content, which would otherwise reach .strip() as a 500) / 400 content_too_long (over 32768 chars, the same cap rewind and fork apply, checked before the destructive boundary), 409 slot_orchestrating / slot_subagents_running (a plan is mid-stage, or sub-agent children are attached to the session the discard would tear down — the same two guards reset-conversation applies), 503 edit_resend_prepare_failed (discard or sid flush failed), 409 edit_resend_session_busy (a channel turn holds the session), 503 edit_resend_save_failed, 503 edit_resend_slot_rebound (the slot moved to another transcript mid-persistence). App tokens are gated by _check_slot_app_ownership, so an app may edit-resend only a slot it owns whose session and transcript are its own — see session → "Edit rewind context boundary"), POST /api/chat/slots/{slot}/rewind (edit any past user message and re-run; fork-and-swap — truncates slot.messages, removes the slot's ACP session via SessionManager.remove, deletes orphaned kiro-cli session JSONL at ~/.kiro/sessions/cli/<id>.json[l], then runs the edited prompt against a fresh ACP session under the same slot key/title/folder. Mirrors kiro-cli /rewind. Body: {at_message_index?, ts?, content}), PATCH /api/chat/slots/{slot}/mode (switch session mode between "" and "orchestrator" — _VALID_MODES; 404 missing slot, 400 invalid mode, 409 while the session is running)
Agent-switch failure feedback: every dashboard caller of POST /api/chat/slots/{slot}/agent — the chat agent picker, the split-view pane picker, and the global forward/backward agent-cycle shortcuts — surfaces a failed request instead of discarding it. The notice carries the server-provided message when the failure has one, and the shared unexpected-error copy otherwise; it makes no assumption about which failures the endpoint can produce. One shared chat-state notice serves every entry point, the always-mounted App shell renders it on every route, and it expires after six seconds. The picker closes on selection either way — its onSelect does not await the switch — so the notice, not the dropdown, is what tells the user the switch did not take.
Context meter survives a reopen: context_usage WS frames are turn-scoped, so a session opened in a tab that did not witness its turns had no usage to render and the bar sat at 0% until the next message. GET /api/chat/slots/{slot} therefore carries {context_pct, context_stale, context_window_tokens?, context_used_tokens?}, computed by _context_snapshot_fields in two tiers: a provider still resident in the session pool is authoritative, and a session whose ACP process is gone (idle timeout or a gateway restart) falls back to a stored snapshot flagged context_stale. context_pct is the load-bearing field and the token counts are optional enrichment — kiro-cli commonly reports contextUsagePercentage with no usage_update, so a resident session routinely knows it is 13% full while knowing neither token count; gating the reading on a known window no-ops the feature for the majority of sessions, and the frontend already supplies a model-derived window. Fields are OMITTED, never zeroed, when there is no reading at all — a 0% pct with no window is indistinguishable from a session that never had a turn, and both render an empty bar, so it is reported as absent rather than as a measurement. A stale reading additionally withholds context_used_tokens: no process measured that count, so shipping it would make any consumer render a never-measured figure as measured; the tooltip derives a ~ approximation from pct instead. A snapshot whose recorded model differs from the slot's current one is discarded, because its pct and counts are denominated in the old model's window. Every failure path degrades to {}: the fields ride on the response that carries the whole transcript, so a non-numeric provider reading is gated by _finite_number and any exception is swallowed rather than turning a display nicety into a 500 that blanks the conversation. DashboardState.broadcast_context_usage(slot_key, payload) is the SINGLE writer for both the frame and the snapshot — every producer (end-of-turn, both compaction paths, the auto-compact callback, and the model-switch/session reset) routes through it, it records a pct-only frame on its own, and an unchanged reading leaves the map clean so the next flush has nothing to write. A post-compaction frame legitimately records pct: 0: that is the new truth, not an absence. All snapshot file IO is off the event loop, which is what the no-blocking-call-on-event-loop rule requires and what _persist_open_slots already does for its own sidecar: broadcast_context_usage only mutates the in-memory map and sets a dirty flag, _persist_context_snapshots runs from _flush_dirty_slots (the periodic executor pass) plus once more on graceful shutdown so a restart — the case the reopen seed exists to serve — does not lose the last reading, and the first disk read reaches ensure_context_snapshots_loaded through asyncio.to_thread from the async slot-detail handler. Deferring the write puts the map on a thread boundary, so every access — event-loop writers, the flush executor, the shutdown thread, the handler's copy-out read (context_snapshot_for) — holds _context_snapshots_lock; the lock covers dict work and serialization only, never the file write, so a stalled disk cannot block the loop's writers. The loaded flag flips only after the disk merge is in the map, so a concurrent flush can never write a memory-only view over prior-process readings it has not merged; disk fills gaps and never overwrites a live entry. Any flush failure re-arms the dirty flag and is swallowed — the flush loop treats a raising callee as fatal, and the next 5s pass retries. Snapshots live in <config_dir>/context_snapshots.json (slot key → {pct, model, window_tokens?, used_tokens?}, 0o600, pruned to the open-slot set on every write) and NOT in the session's metadata line: ConversationLog.update_metadata rewrites the whole transcript to edit its first line, so paying that per turn would scale a turn's I/O with transcript size while holding the cross-process lock. Ephemeral (incognito/temporary) slots are excluded, matching _persist_open_slots. Frontend: seedContextUsage in chatSlice.ts is the single hydration path for all three slot-detail reducers (switchSlot/refreshSlot/warmSlotCache) and seeds ONLY a slot with no entry yet — the server broadcasts over WS before the HTTP response lands, so an unconditional write would clobber a live measurement with the older snapshot the request was built from. A token entry is written only when a window is actually known.
Follow-up suggestions (suggest_followup MCP tool → card above the composer): POST /api/chat/slots/{slot}/followup with {items: [{title, description, prompt, branch?}]} — broadcast-only (nothing persisted server-side), 404 unknown slot, re-validates SUGGEST_FOLLOWUP_SCHEMA server-side (≤3 items; title ≤120, description ≤600, prompt ≤8000, branch ≤80 chars full-matching FOLLOWUP_BRANCH_RE; hidden-Unicode stripped), redacts every field through redact_exfiltration_urls + redact_credentials — including branch, which is DROPPED rather than mangled when redaction alters it — and emits followup_card over WS. The card goes out on the OWNER-only websocket channel (deliver_ws_owners), never the all-clients broadcast, because an app caller can open /api/ws and would otherwise receive another user's complete handoff prompts. Returns {ok, delivered} where delivered is the number of owner-socket sends that actually COMPLETED — the send is awaited (deliver_ws_owners), not fire-and-forget, because a socket count is taken before any send runs and a window that drops in that gap would be reported as delivered; failed sends are dropped from the owner set. A card with no listener is reported to the model as not shown instead of a false success. Dashboard sessions only, at both layers: the MCP tool rejects non-dashboard: session keys, and BOTH endpoints require the OWNER's own identity via is_owner_dashboard_request — the same predicate the source-provider mutations use: an app claim of "" is necessary but not sufficient, since a dashboard credential minted for a different subject carries it too and would raise cards in the owner's composer and create branches in the owner's repositories; when no owner is configured only the signed local bootstrap subjects (local-app, local-startup) are accepted, which is the standalone-local case. One carve-out: the loopback internal-secret grant sets request["internal_auth"] = True and NO app claim (that is the path every MCP call arrives on), so it is permitted. A request with neither marker really did skip authentication and is refused, SEL-audited. POST /api/worktree/create with {repo, branch} creates <parent>/<repo>-wt-<slug> on a new branch off origin/HEAD (falling back to HEAD) and returns {ok, path, branch, base, reused}. Security boundary: repo must name or sit inside a directory an existing chat slot is scoped to (the submitted path AND the resolved git toplevel are both checked, and only the server-held root is ever used as a path); sensitive paths refused; git is routed through the sandboxed_spawn_argv chokepoint in strict mode (OS isolation + scrubbed env; strict because the filter probe passes --includes and repo-controlled include.path could otherwise make git read ~/.aws/credentials as config); a host with no sandbox backend and no agent.sandbox_allow_unsandboxed_exec opt-in gets a 503 instead of an unisolated spawn. On top of that: an argv list with no shell, run_limited (resource limits applied after exec), 120s timeout, and -c core.hooksPath=<os.devnull> -c core.fsmonitor=false so no repo-supplied program executes (a non-directory device has no hook to find and nowhere to plant one; an in-repo path is repo-writable and a gateway-owned temp dir is still same-uid writable between calls) — a repo declaring a filter.*.{process,smudge,clean} driver in EITHER repository config scope (--local, and --worktree whenever extensions.worktreeConfig is on — probe first, classify a failed probe after per kiro_crew.git_worktree_scope: only a probe that failed because the per-worktree config.worktree was never created counts as a healthy empty scope) is refused (409) because -c cannot disable an arbitrary filter name; the branch must also be a ref git accepts (foo..bar, trailing ./.lock, and HEAD are rejected up front rather than after the claim); both probes pass --includes (git defaults it OFF for a specific-scope query, so a driver reached via include.path was invisible yet still ran on checkout), and an unreadable scope is refused too. The allow-list collection and the sensitive-path/isdir screens run on a worker thread, so a slot project on stalled storage cannot block the event loop. Concurrency: the branch is claimed atomically (update-ref <ref> <base> "") and the destination by os.mkdir, both before anything else, so cleanup after a failure deletes only what the request proved it created, pruning before the branch delete so an rmtree fallback cannot leave the claimed branch behind, and skipping the delete entirely when the post-prune listing shows another worktree holding that branch or cannot be read at all (update-ref -d lacks branch -D's used-by-worktree guard, so deleting an adopted branch would strand that worktree on a dangling ref); a failed worktree list is treated as unknown (503), never as "nothing registered". Reuse requires the destination to be registered on the REQUESTED branch (_dir_slug keeps only a branch's last segment, so feat/foo and fix/foo collide). Both endpoints are SEL-audited. Frontend: both card actions PRE-FILL a composer and never send, appending below an unsent draft rather than replacing it (the pending-input path also persists the draft, so a plain set destroyed in-progress user text); the worktree action creates the tree, opens a session WITHOUT activating it, scopes it, and only then activates it and prefills — so the composer is never live in the default directory (and fails closed rather than prefilling an unrelated one), and deletes the session it just made if scoping fails. Per-slot card state is pruned on slot delete and in stale-slot pruning. User-facing guide: src/kiro_crew/docs/followup-suggestions.md.
Pull-request sources: POST /api/source/pull-request with {url, refresh?} returns the normalized full GitHub PR or GitLab MR payload. POST /api/source/pull-request/checks with {url} returns {checks} through a lightweight one-call GitHub or at-most-two-call GitLab path without rewriting the full-source cache. POST /api/source/pull-request/status with {urls: [...]} (bounded to STATUS_URLS_MAX = 64 canonicalized URLs, non-PR/MR URLs dropped, non-list bodies 400) returns {statuses: {url: {state?, ci?, mergeable?, mergeStateStatus?}}, refreshing: [...], ttlSecs} read straight from the same short-TTL chip-status cache the sidebar uses — it never blocks on a provider call, and schedules the bounded background refresh for stale entries, so unknown URLs are simply absent until a later poll. refreshing is the scheduler's own report of URLs whose value is expected to change shortly (started or already in flight; pending-cap deferrals excluded) and ttlSecs is the cache TTL, so a client paces its steady state by the server's TTL and re-polls within seconds of a refresh landing instead of up to one extra interval later. The Changes-tab source strip polls it to mark every PR/MR tab with its lifecycle state and CI rollup (bounded fast follow-ups, then TTL pacing; failing polls back off exponentially to a 5-minute ceiling rather than stopping, and re-poll on reconnect), and drives the selected tab from the full payload instead. The strip polls rather than riding the WS slots channel that already carries cached state/ci for sidebar chips: slot source_links are capped at a handful per slot while the strip shows up to 64 sources, so widening every slots push for one panel would cost every client more than one bounded request per TTL costs this one. One truth for two surfaces: the full-payload cache and the lightweight chip cache are kept coherent instead of expiring independently (which let the sidebar chip and the detail panel render different lifecycles for the same PR while both were nominally "fresh"). A completed full fetch projects its state/draft/checks onto the chip cache via status_from_full_payload (record_full_payload_status), and a chip refresh that observes a changed status conversely drops the full payload for that URL, so the panel's next read cannot serve a lifecycle the chip has already moved past. The chip entry carries the normalized merge pair alongside {state, ci} — free on the GitHub chip read and already present in GitLab's payload — and status_from_full_payload projects it too, because a write-through that dropped it would make every full fetch look like a chip change and drive exactly the mutual-invalidation loop below. Each field is recorded independently and only once it is real, never as unknown: independently because GitLab settles need_rebase and its branch-protection gates in the detail field while mergeable stays unknown, and never-as-unknown because a still-computing read must not read as a change away from a real answer. Because the pair participates in the chip cache's change detection, a branch that starts conflicting while the panel is open drops the full payload and pushes a delta like any other status change — the panel re-reads and banners it without a manual refresh. A structural loop-breaker guards the two comment-mirrored projections: _refresh_check_status records each URL's changed transition, and once the identical (previous → new) transition repeats past _CHECK_FLAP_DAMP_THRESHOLD consecutive refreshes — the signature of a chip↔full vocabulary divergence — it stops invalidating the full payload and emitting deltas for that URL and logs loudly once, degrading a would-be unbounded provider-polling loop to a stale glyph; a genuinely changing PR produces distinct transitions, which resets the counter and clears the damp. Turn-boundary refresh: when a chat slot goes idle, DashboardState.refresh_slot_source_status re-reads that slot's serialized chip URLs through request_check_refresh_now, which bypasses the chip TTL — an agent turn that opened a PR, pushed a revision, or drove a review round is the moment the remote state most likely moved, and TTL rotation alone can lag it by minutes once there are more PR-linked slots than CHECK_STATUS_PENDING_MAX. It is gated on at least one owner websocket (status is credential-backed and nobody else can render it, so a headless gateway spawns no provider work), scoped to the one slot that finished, floored to one forced read per URL per _CHECK_FORCE_MIN_INTERVAL_SECS (URLs inside the floor fall back to plain TTL pacing), and best-effort — a failure is logged and can never break turn completion. Status deltas push instead of polling: whenever a URL's cached status — {ci, state} or the merge pair — changes, the owner-only source_status WS event carries {url, origin, ci?, state?, mergeable?, mergeStateStatus?} to owner sockets (DashboardState.push_source_status, registered once as a delta sink at app wiring and unregistered on on_cleanup). origin ("chip" = the lightweight path learned it, "detail" = a full fetch's write-through produced it) is diagnostic only — the client patches its cached status batch AND invalidates ['pull-request-source', url]/['pull-request-checks', url] for every changed delta regardless of origin. It must invalidate on "detail" too, because that delta is emitted by the single window whose full fetch ran; only that window received the fresh HTTP payload, so the other owner windows (whose detail query is staleTime: Infinity) would otherwise keep rendering the pre-change lifecycle. The initiating window's resulting refetch is harmless and cannot loop: record_full_payload_status runs only in the uncached fetch path, so the refetch hits the warm 30s cache and emits no further delta. The origin field is retained on the wire for diagnostics and possible future requester-aware routing; no consumer branches on it today. The client additionally invalidates pull-request detail queries on chat_done, because lifecycle/CI deltas do not cover review comments or mergeability and the detail query is otherwise staleTime: Infinity (it would never refetch after mount). The invalidation is scoped to the finished slot: for the ACTIVE slot it refetches the MOUNTED detail query with refetchQueries({queryKey: ['pull-request-source'], type: 'active'}) (the PR on screen, which may lie outside the serialized chip subset; refetchQueries marks nothing else stale, whereas invalidateQueries with refetchType: 'active' would still mark every cached PR stale) and marks that slot's own source_links change URLs stale for their next mount; for a BACKGROUND slot it only marks that slot's own URLs stale (refetchType: 'none', never a refetch of an off-screen PR). It never invalidates the whole ['pull-request-source'] family — doing so marked every session's PR stale on every turn anywhere, so a panel reopened while any chat was running always paid the full provider fanout even when nothing about that PR had changed — and the panel's own mutation success handlers invalidate only ['pull-request-source', url] for the same reason. A background slot with more PRs than serialized chips has its overflow left to the status-delta path. The detail queries (PR and issue) also keep an unmounted payload for SOURCE_DETAIL_GC_MS (one hour) instead of React Query's five-minute default, and both detail queries revalidate on mount whenever the retained data is older than the gateway's cache window (SOURCE_REMOUNT_REVALIDATE_MS, 30s — refetchOnMount answers 'always' past it, since with staleTime: Infinity a plain true would never refetch), so a reopened panel renders the retained payload at once and refetches in the background — stale-while-revalidate — rather than presenting an hour-old discussion as current when the only events this gateway sees are its own turns and status deltas. Younger data is not refetched: the gateway would return the same cached bytes, and Code Review Sage mounts its pane and this panel on the same key, which must stay one provider read per open. Past the window the refetch is cheap because the gateway revalidates with conditional GETs. A background revalidation that fails over a loaded payload (expired provider login, registry outage) renders a compact role="status" notice above the retained content — the last loaded version is showing, with the login command and a retry — never the full-height "could not load" card, which is reserved for a panel with nothing to show. Polling remains the safety net for a missed event. POST /api/source/pull-request/resolve with {url, threadId}, POST /api/source/pull-request/auto-merge with {url, confirmImmediateMerge?}, and POST /api/source/pull-request/ready with {url} are the three lifecycle mutations, and POST /api/source/pull-request/submit-review with {url, reviewId, event} is the fourth. Alongside it POST /api/source/pull-request/pending-review with {url} is a fourth READ, returning {reviewId, body, commitId, headSha, stale, contentRedacted} for the caller's own unsubmitted (PENDING) review or empty strings when there is none. The pair exists so a draft review can be published from the dashboard instead of only in the provider UI: GitHub scopes a PENDING review to its author and allows one per user per pull request, so the single PENDING entry the read returns is necessarily the caller's own — there is no way to observe, or therefore publish, someone else's. event must be APPROVE, REQUEST_CHANGES, or COMMENT (DISMISS and the other review endpoints are deliberately unreachable: this path publishes a draft, it does not act on reviews others submitted), reviewId must be a bare positive integer since it is interpolated into the REST path, and both are GitHub-only — a GitLab merge request is a 400 rather than a silently different code path. reviewId is REQUIRED rather than resolved server-side: a pending review may equally be one the human started by hand in the provider UI, so submitting whatever draft happens to exist could publish an unfinished review the caller never saw; the id the caller was shown is re-checked against the still-pending one and a mismatch is a 400, which turns a concurrent submit-or-replace into a rejection instead of a surprise. The id is necessary but NOT sufficient: it identifies the review OBJECT, and GitHub lets a pending review's body be edited and its inline comments added or removed under the same id, so an id match alone cannot prove the caller read what is about to go out. The read therefore also returns contentDigest -- a sha256 over the review body plus every inline comment's (id, path, line, body), sorted by comment id so a re-ordered read of identical content yields the same digest while an edited body, a moved line, or an added/removed comment all change it -- and the submit REJECTS a digest that no longer matches the current content. contentDigest is REQUIRED, not optional: a digest compared only when present would be a one-parameter bypass of the whole binding, so an empty value is a 400 rather than a skipped check. The digest is taken over the RAW text, because raw text is what GitHub publishes. Two further guards fail the submit CLOSED, and the read reports both so the UI withholds the buttons rather than offering a doomed click. Stale head: the draft's commit_id must equal the pull request's live head.sha, because a repository without stale-approval dismissal counts a stale APPROVE as a live approval of code nobody read; every event is refused on a moved head, not just the verdicts, since inline comments anchor to lines that may be gone, and an unknown draft or head sha counts as stale. Redaction mismatch: submission publishes the draft GitHub stored, NOT the redacted copy the read returned, so a draft whose body or any of its inline comments is ALTERED by _redact_provider_data is refused outright -- otherwise the dashboard shows [REDACTED] while the published review carries the secret verbatim, which is worse than having no publish button. Inline comment bodies are read separately (.../reviews/{id}/comments) because submission publishes every comment the draft holds, not only the body this app can see -- and BOTH that call and the reviews list itself run --paginate --slurp, because each returns 30 per page: an unpaginated comment scan clears a draft whose 31st comment carries the credential and then publishes it, and an unpaginated reviews read reports "no draft" for a pending review sitting past page one. Post-submit head re-check: GitHub's submit-review API accepts no expected-head parameter, so the staleness check and the submit cannot be one atomic operation. For a GATING verdict (APPROVE / REQUEST_CHANGES -- a COMMENT carries no verdict) the head is re-read AFTER publishing, and a head that moved in that window makes the just-published review get dismissed again (.../reviews/{id}/dismissals) with the reason recorded; a dismissal that itself fails raises a louder error naming the review, because an undismissable stale approval is the one state a human must be told about. This is a compensating action, not atomicity: it converts an invisible race into a visible, self-reverting one -- with ONE case it cannot repair, which is why the read also reports autoMergeArmed and APPROVE is REFUSED while auto-merge is armed on the pull request: an approval then satisfies branch protection and GitHub can merge the unreviewed head BEFORE the dismissal lands, and nothing repairs a merge. The refusal is scoped to that combination rather than removing the verdict, because COMMENT and REQUEST_CHANGES cannot let a merge through, and an unrecognised auto_merge shape counts as armed (fail-closed). Stale-approval dismissal is the setting that closes the class: every remaining variant of the race -- auto-merge armed before or after the check, a force-push inside the submit round trip, a manual merge in that window -- needs the base branch to KEEP stale approvals to do harm, because with dismiss_stale_reviews on GitHub retracts the approval itself the moment the head moves. So APPROVE additionally requires required_pull_request_reviews.dismiss_stale_reviews on the pull request's base branch, read from repos/{o}/{r}/branches/{base}/protection and reported to the client as staleDismissalEnabled. Unreadable protection (no admin rights, or no protection at all) withholds APPROVE -- fail-closed, since an unanswerable question about safety is not an answer of safe. The UI withholds the Approve button and states the reason instead of offering a click that 400s. Submitting is irreversible and immediately visible to everyone on the pull request, so it is the most consequential of the provider writes — the LLM review poster is separately forbidden from ever submitting (see the Code Review Sage app), and this endpoint is what makes the HUMAN's own click the only path to a published verdict.. All three go through one auth/audit/error wrapper (_owner_mutation_response), so a client disconnect, a rejected request, and a provider failure are recorded identically across them. Every credential-backed endpoint requires the explicit dashboard-user claim request["app"] == "". When state.owner_id is configured, all eight endpoints require an exact request["user"] == owner_id match. When no owner is configured, only the four read endpoints accept the signed machine-local bootstrap subjects local-app and local-startup; every mutation still returns 403. App tokens, non-owners, unrelated or missing subjects, and every unconfigured-owner mutation fail closed. Every direct source request makes a best-effort SEL audit attempt with only the caller, operation, coarse outcome/reason, never URL, thread id, provider output, or credentials. SEL write failure cannot weaken a denial or replace the request's response or exception. Provider CLI calls separately emit coarse invoked, completed, denied, or failed tool-invocation events with no argv or provider-controlled text. Cancellation while reading a request or awaiting a provider is recorded as failed/request_cancelled when SEL is available, then the original cancellation is re-raised; because a remote mutation may already have landed, mutation cancellation is intentionally an uncertain failure outcome. Cache removal, generation advancement, and stale in-flight detachment complete before provider mutation dispatch, so a cancellation cannot preserve or repopulate pre-mutation data. A mutation invalidates both caches: the full-source payload and the separate short-TTL chip-status cache the sidebar and /api/source/pull-request/status read, which would otherwise keep serving pre-mutation draft/CI state for up to one TTL. The chip cache carries its own per-URL generation counter for the same reason resolve advances the full-fetch generation: a status refresh that started before the mutation captures the generation at entry and discards its result if it changed, so an in-flight fetch cannot restore superseded state. The resolve endpoint validates thread ownership and resolves a supported review thread, returning {resolved: true}. The auto-merge endpoint arms provider auto-merge and returns {autoMerge: true, mergeMethod}: on GitHub it reads the pull request node plus the repository's allowed merge methods, refuses a draft or an already-armed pull request without dispatching, picks the first repository-allowed method in squash/merge/rebase order, and calls enablePullRequestAutoMerge; GitLab has no separate switch, so it reads the merge request first, refuses a draft or an already-armed merge request, and otherwise issues the merge call flagged merge_when_pipeline_succeeds; because that call merges immediately when no pipeline is pending, the GitLab path is a merge authorization, so with no pending head pipeline the request is refused unless the body carries confirmImmediateMerge: true. The field must be a real JSON boolean -- coercing it would let any truthy value, notably the string "false", read as consent -- and any other value is a 400. That refusal is raised as ConfirmationRequired and answers with {error, confirmationRequired: true}, which is what makes the guard live rather than a constant the client asserts: the dashboard's confirming click sends false, and only the server's own refusal escalates the panel to a third, explicitly-worded Merge now step that sends true and quotes the server's reason. Confirm and Cancel are separate buttons, with Cancel standing where the arming button was, so the second half of an accidental double-click backs out instead of authorizing a merge. The ready endpoint clears draft state and returns {ready: true}: markPullRequestReadyForReview on GitHub, and on GitLab the GraphQL mergeRequestSetDraft(draft: false) mutation, refusing when the merge request is not a draft (draft, or legacy work_in_progress). Neither ready path rewrites the title: the draft-prefix grammar stays the provider's concern, a concurrent retitle cannot be lost, and a title that merely begins with a draft-like word (Drafting widgets) is never mangled. GraphQL reports refusals in the body with HTTP 200, so every mutation response is inspected for transport-level errors and the per-mutation errors field and raises rather than reading as success. Both read the current draft/auto-merge state before mutating so an inapplicable action is a 400 instead of a provider error, and both invalidate the cache before dispatch on the same rule as resolve. The full payload carries autoMerge (GitHub autoMergeRequest, GitLab merge_when_pipeline_succeeds) so the panel renders armed auto-merge as state rather than an available action. Invalid URLs/thread IDs return 400; provider CLI, authentication, secure-spawn, audit, or direct-fetch-capacity failures return 503. Sidebar source-link extraction ignores every non-durable message role (chunk, done, streaming, queued, and permission) and indexes only durable message content, preventing partial output, queue placeholders, and permission prompts from scheduling credential-backed provider work. Sidebar provider refresh and cached state/ci serialization use the same read-only boundary: an exact configured-owner request, or a signed local-app/local-startup dashboard request when no owner is configured. Non-owner and app-token slot responses retain the source URL/provider/number but cannot trigger or observe credential-backed status.
Chat Folders: GET /api/chat/folders (list project folders, each enriched with a computed non-persisted history_count — the authoritative on-disk archived-session count per folder from ConversationLog.list_sessions()), POST /api/chat/folders (create; body {name, parent_id?, project_dir?, default_agent?, color?, icon?, tags?} — color is allowlisted against the folder palette (_FOLDER_COLOR_PALETTE, pinned to the frontend catalog by test), rejected with 400 code: "color_invalid" otherwise; tags is an array of tag ids filtered against the live tag vocabulary (deduped preserving order; only a non-array payload is rejected with 400 code: "tags_invalid" — unknown and non-string ids are silently FILTERED, not 400ed, matching the slot-tags endpoint, since a dangling id can legitimately exist on a folder and a strict endpoint would brick every subsequent save; the intersection is skipped when the vocabulary is not authoritative (tags.json unreadable at boot), failing open so a save cannot wipe stored tags; no count cap), persisted on the folder record only when non-empty (absent means no tags); icon is an optional explicit emoji (grapheme-exact single emoji, validated in create_folder_record next to color, 400 code: "icon_invalid" otherwise) — create NEVER generates: absent, null, and explicit empty-string icon are equivalent (no icon stored, the folder keeps the default glyph, no model call). Generation is explicit-only, via regenerate_icon on PATCH below (the Auto-generate button); that path's fire-and-forget background task (_spawn_chat_folder_icon_task, the chat-folder sibling of the artifact-library generator) derives an emoji from the folder name on the governed cheap-model one-liner path and writes it back through mutate_folders under the store lock, re-finding the folder by id (a folder deleted mid-generation is never resurrected) and only while the folder's icon epoch is unchanged since schedule time (a per-folder in-memory counter bumped under the store lock by every icon set, icon clear, and rename — any of those landing while the LLM runs invalidates the stale result); the generated icon arrives via the slots push), PATCH /api/chat/folders/{id} (update — accepts hidden (bool), color (allowlisted; empty string or null clears back to default) and tags (validated as on create; an empty array clears the key) alongside name/collapsed/order/default_agent/project_dir; a non-object JSON body is 400 code: "invalid_json"; icon (single emoji sets it; empty string or null clears back to the default glyph — absent-means-default stays the one on-disk representation, mirroring color), regenerate_icon (strict boolean, 400 code: "regenerate_icon_invalid" on a non-bool; true runs the background generator from the folder's current name after the update applies — mutually exclusive with icon in the same request, 400 code: "icon_conflict"); a folder's emoji icon replaces only the default glyph shape while the palette color keeps tinting the default glyph, rename never regenerates, and icon/regenerate_icon ride the validate-then-apply changes dict so app-ownership enforcement applies unchanged (the artifact-library folder emoji generator is the same underlying engine, wired separately); moving or reviving a session into a folder auto-unhides it via _unhide_folder), DELETE /api/chat/folders/{id} (delete + ungroup its slots), POST /api/project-scaffold/scan (dry-run preview of the folder tree a project directory would produce — read-only, creates nothing), POST /api/project-scaffold/create (create the previewed selection; both are specified in "Chat Folder Scaffolding" below) Folders may be linked to a project directory (project_dir, validated server-side: absolute, existing, non-sensitive path): POST /api/chat/slots resolves the effective directory from the server-held folder tree by walking to the nearest ancestor with project_dir set (cycle-guarded), validates the stored path again, and initializes a new slot with it before the first broadcast. The dashboard still carries its client-resolved directory through the existing explicit project endpoint as compatibility defense, but correctness does not depend on the React Query folder cache. A slot is born filed: POST /api/chat/slots accepts folder_id (validated against the folder list, 400 on unknown) and the create handler runs the whole set-up — folder, title, artifact binding, project — inside DashboardState.suspend_slots_push(), so exactly ONE coalesced slots broadcast is emitted and it already carries the folder. A genuinely NEW slot filed into a folder that carries tags also inherits them at creation (copy-by-value onto slot.tags, ids re-validated against the live vocabulary, non-string entries skipped; addressing an EXISTING slot never re-tags, and moving a session into a folder later never retro-tags — creation-only, mirroring fork tag inheritance). Inheritance is DIRECT-FOLDER-ONLY: unlike project_dir, which resolves by walking up to the nearest ancestor that sets it, a chat created in a subfolder does not inherit an ancestor folder's tags — tags mark the folder itself, not its subtree. The per-channel default-filing path (channel_slots.surface_channel_session) applies the same copy on its first-filing branch, so a channel chat born into a tagged folder inherits identically; its restore branch (a persisted folder_id) does not. default_agent follows the project_dir rule rather than the tag rule: an empty default_agent on a subfolder means "inherit", so website/src/utils/folderAgent.ts::resolveFolderAgent walks up to the nearest ancestor that pins one (sharing resolveFolderProjectDir's Set-based cycle guard through one nearestFolderValue helper) before falling back to the global default, and FolderConfigModal's empty "Inherit (…)" option names that resolved ancestor agent — naming the global default there contradicted the agent the subfolder's chats actually run. That walk is FRONTEND-ONLY, which is the one place this rule is weaker than project_dir's: the sidebar create path sends the resolved name as the request's explicit agent, and the handler has no folder-agent counterpart to _resolve_folder_project_dir — an agent-less create falls straight through to cfg.default_agent. So every non-sidebar create-in-folder path (cron folder assignment, channel_slots.surface_channel_session, MCP slot creates) still gets the global default and ignores the folder's agent, a DIRECTLY set one included; unlike project_dir, correctness here does depend on the React Query folder cache. This ordering is load-bearing, not an optimization: get_or_create_slot broadcasts the slot list before the handler returns, so a folder applied afterwards (the former client-side setSlotFolder PATCH) always lost the race — the client received the slots frame for an unfiled slot first and rendered the new session at the top level for ~200ms before it jumped into its folder. _folder_changed is deliberately NOT set at creation: the first turn is is_new, which already injects the [FOLDER] breadcrumb.
Chat Folder Scaffolding (dashboard/chat_folder_scaffold.py + the pure scanner project_scan.py): a monorepo, or a directory of sibling repositories, needs one folder per package before per-package steering loads at all — and assembling N sub-folders by hand is work nobody does, so the two endpoints above detect the packages and then build the tree through the ordinary folder create path. The split into preview-then-create is the safety property, not a convenience: scan is read-only, so the destructive-sounding half of "build me twenty folders" only ever runs against a selection the user has seen. POST /api/project-scaffold/scan — body {root}. The root goes through the SAME _validate_project_dir a manual folder create uses, so the scan refuses exactly what creating a folder by hand refuses and answers with the identical prose (project_dir must be an absolute path / refers to a sensitive path / must be an existing directory) under code: "folder_scan_root_invalid"; an empty root is folder_scan_root_required, which has to be caught separately because the validator ACCEPTS "" (a folder is allowed to have no project directory) and a rootless scan would otherwise fall through and scan nothing. The root is additionally refused when it CONTAINS a protected location (path_contains_sensitive — the reverse direction of the validator's own check, because the scan sweeps everything below the root), and as defense in depth the scanner's two file reads (declared_patterns, _gitignore_spec) refuse a path inside a protected location whatever the root was. Root resolution (realpath/isdir against a path the user merely named), the config read (both asyncio.to_thread) and the walk (discovery_executor(), the pool purpose-built for browser-triggerable read-only filesystem discovery — not the subprocess pool, which must stay free for PTY teardown and wedge recovery, and not the maintenance pool, whose periodic sweeps must stay responsive) all run OFF the event loop, because the cost scales with a tree the user merely pointed at and one stalled network mount would otherwise stall every chat, WS push and heartbeat behind it. Response: {root, root_existing, status, candidates: [{path, name, parent_path, tier, signals, existing, selected}], warnings}. Zero candidates is HTTP 200 with status: "empty" — an answer (this tree holds no packages the scanner recognizes), not a failure a surface has to render as an error; anything else is "ok". Each candidate carries its parent_path, and a surface derives whatever grouping it needs from that — the flat candidates list stays the single copy of each candidate. Detection is two-tiered because the confidence differs. A .git or .kiro directory is unambiguous at any depth → auto (ticked by default): a nested repository is its own package, and a directory already used with Kiro is one the user has already treated as a project root. A recognized manifest — package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, build.gradle, build.gradle.kts, build.sbt, pubspec.yaml, composer.json, Gemfile, mix.exs, Package.swift, deno.json/deno.jsonc, plus the deploy-root markers firebase.json, vercel.json, netlify.toml, amplify.yml, serverless.yml/.yaml, cdk.json, wrangler.toml/.jsonc, fly.toml, render.yaml, and Procfile (a deploy config marks a deployable unit that often carries no build manifest at its own level — the package.json files of a Firebase app live in its functions/web children; deliberately generic names like template.yaml, app.yaml or Dockerfile are excluded) — is the ambiguous case and POSITION decides it: outside any detected package it names the package itself (auto), inside one it may just as easily name a build fixture as a real sub-package (offered, shown unticked) — EXCEPT the deploy-root markers, which stay auto at any depth, because a deploy config never names a build fixture: someone deploys that directory, so a monorepo's apps stay ticked even though the workspace root wraps them in a package. A directory named by a workspace declaration (workspaces in a package.json, including yarn's {"packages": [...]}; pnpm-workspace.yaml; Cargo [workspace] members; go.work use, both spellings) is offered, and being named in a list never DEMOTES a candidate that already earned auto — it only gains the member signal, so a preview can show both reasons. !pattern exclusions are honoured and subtracted, because an excluded directory is one the project has already said is not a member. A directory with no signal at all is absent from the tree entirely rather than carrying a third tier, so a surface that forgot to filter cannot render an ignored directory. The scan root itself is never a candidate (its folder is created by the scaffold step, from the root path directly), but its own signals still count: a root holding a manifest makes everything below it nested, which is what puts a monorepo's packages on the unticked tier while a directory of unrelated repositories puts them on the ticked one. Prune beats every signal, one-directionally: node_modules, dist, build, target, env, venv, .venv, __pycache__, DerivedData, plus any dot-directory except .kiro, are rejected BEFORE classification (they never enter the walk stack at all), so a vendored package.json under node_modules cannot become a candidate however well it matches — the opposite ordering (classify, then filter) leaks a candidate the moment a signal is added without a matching filter, so member-pattern expansion walks segment by segment under the same limits rather than calling glob.glob, which honours none of them. Gitignored is pruned with the same precedence: the project's .gitignore files (at or below the scan root only — the scanner never reads outside the tree the user pointed at) are honoured with git semantics via pathspec (nested files stack and the deeper wins, dir-only pattern/ forms, !negation re-includes), an ignored directory is never entered or classified — a .git inside cannot rescue it, which is what defeats SwiftPM's derived_data/SourcePackages/checkouts store of full dependency clones — an ignored file is neither a manifest signal nor a workspace declaration, and a declared member inside an ignored directory is dropped; an unreadable or oversized .gitignore costs a warning and that file's layer, never the scan. Linked directories are never traversed, and the gate is the property rather than a list of link kinds: an entry whose name resolves somewhere other than itself — a POSIX symlink (is_dir(follow_symlinks=False)), a Windows junction (whose listing-time reparse tag carries the name-surrogate bit; mklink /J needs no elevation, so a privilege argument never covered it), or whatever redirecting reparse kind ships next — is kept out of the walk, while in-place reparse decorations like cloud placeholders still scan. Because listing settles what a child is while the descent re-resolves it by NAME, the descent is descriptor-pinned where the platform allows — opened O_NOFOLLOW/O_DIRECTORY and checked against the (st_dev, st_ino) the parent recorded, so a directory swapped for a link in between is refused rather than read through (a path-based read would yield entries from outside the root under a path string that still looks inside it, which string containment cannot catch). A platform with neither the flags nor a per-entry inode (Windows reports a zero inode from a directory listing, recorded as no identity at all) verifies resolution instead: at descent the directory's real path must equal its parent's real path plus its own name, so a pre-planted link of any kind is refused no matter when it was created — inductively up the walk, since every ancestor passed the same check when it was the leaf being descended, with the scan root the caller's to validate (the endpoint realpaths it). What that branch still cannot close is a swap landing between the check and the read; it has no way to make them one operation, and states that window plainly rather than reasoning it away. A pre-existing link's target is therefore never read on any platform, whether it points inside the root, outside it, or back at an ancestor, and a declaration file is read only when found as a regular file — following a link would read a file outside the tree the user pointed at and quote its content back in a parse warning. The scanner's own DEFAULT_DEPTH_CAP (5) bounds the descent; a declaration larger than 512 KiB is refused rather than parsed truncated; and pnpm-workspace.yaml is parsed by a SafeLoader subclass that refuses YAML ALIASES outright, since a small file can otherwise compose a graph orders of magnitude larger than itself and no real member list needs one. An unreadable subtree or an unparsable declaration becomes a warnings entry and the scan continues — one unreadable directory must not cost the user the other twenty packages. A .gitignore pattern the grammar refuses (a lone !) is converted to the same refusal an oversized file gets, costing the warning and that file's layer rather than surfacing as a 500. And warning REASONS are redacted at construction (redact_exfiltration_urls + redact_credentials in _warning_reason): a parse error quotes the offending source line back, and that line is tree content — a credential in a malformed declaration must not ride the warning into the scan response. Reconcile marking is an overlay, not a detection rule, which is what keeps the scanner's output a function of the filesystem plus its two arguments and nothing else (two scans of an unchanged tree compare equal): the endpoint marks a candidate existing: true when its path EXACTLY equals a project_dir some folder already holds. Exact equality is the whole comparison — both sides come out of the same realpath — where prefix matching or re-resolving a stored path could mark a sibling directory as taken. An existing candidate is still REPORTED ("already set up" is information the user wants) but is never ticked whatever its tier, so a re-scan offers additions only; the store is read AFTER the walk, so a folder created while the scan ran is reflected rather than offered again. POST /api/project-scaffold/create — body {root, selected: ["<candidate path>", ...]}. The selection is re-derived, never trusted: the tree is scanned AGAIN here, a selected path is created only if THIS scan offers it as a candidate, and the folder is created on the scanner's path rather than the string the request carried — so a client picks from what the server just found and can never name the directory a folder lands on. That makes the difference between a stale preview (the user's tree changed under them) and a forged one (a path nobody was ever offered) irrelevant, since one test refuses both: anything outside the fresh candidate set is a 400 folder_scaffold_selection_stale carrying up to MAX_REPORTED_UNKNOWN (20) unknown paths — capped because the request body it comes from is caller-controlled and a response must not grow with it — plus a SEL denied audit, because the request asked for a folder on a directory this server never offered even when the cause was a preview left open too long. A selected that is not a list of strings is folder_scaffold_selection_invalid; an absent or null selected is legitimate and means "the root folder, none of the packages". Creation order is the scan root's folder first, then candidates in the tree's path-sorted order, which puts every ancestor before its descendants (an ancestor's path is a prefix of theirs) and is therefore parent-before-child by construction. Every folder goes through create_folder_record, one call per folder, so each is name/path-validated and appended under the folders lock exactly as a hand-created folder is — no new persistence, no second write path. One folder per directory is atomic: the scaffold opts into create_folder_record(unique_project_dir=True), whose check runs inside the locked append — two concurrent scaffolds of the same tree cannot both observe a path unclaimed and both persist it; the loser's path lands in skipped_existing and the winner's folder is still the parent its children hang off. A path must still name the directory the scan confirmed at create time: the scaffold also opts into require_resolved_project_dir=True, which refuses (folder_project_dir_moved, audited as a SEL denial) any project_dir whose validation resolves away from the scanner's own canonical path — a component swapped for a link in the scan-to-create window would otherwise bind the folder to a directory outside the confirmed root. A swapped root costs the whole call; a swapped candidate costs only its own path. The folder API proper leaves the flag off, since a person naming their own ~ or symlinked path is resolution working as intended. The scaffold write carries the same three guards the folder-create route applies: an unattributable caller (a dashboard: session key naming a popped slot) is refused 403 caller_unattributable before anything is created; an internal caller consumes ONE FOLDER_CREATE rate-budget unit per scaffold call (the browser is never throttled) so this route is not the loophole around the create route's limiter; and the caller's app identity — _effective_request_app, never the body — is passed to every create_folder_record, so a scaffolded folder is stamped owner_app exactly as a hand-created one and an app's create under a folder it does not own is refused. An ownership refusal is a FolderCreateError subclass, so it lands in failed for that path like any other per-folder refusal. A child whose parent was left unselected or whose parent failed hangs off the nearest ancestor that DOES have a folder, ending at the root's, keeping a partially-selected tree shaped like the tree the user saw rather than flattening it. Response: {root, created: [{path, folder_id, name}], skipped_existing, failed: [{path, error, code}], warnings}, with exactly one slots push and only when something was created. Additive only: no existing folder is deleted, renamed, or modified and no existing folder's settings change; an already-scaffolded candidate is reported in skipped_existing and is still the parent its own children hang off; and a failure partway through is REPORTED rather than rolled back, because rolling back would delete folders that may already hold conversations by the time the next creation fails. The one short-circuit is the root folder itself: if IT cannot be created the call returns immediately with that single failure, since the whole selection would otherwise land as unrelated top-level folders — a worse thing to hand back than "nothing was created". Both endpoints SEL-audit (chat.folder_scan, chat.folder_scaffold) with the root plus COUNTS (candidates, or created/skipped/failed) and never the candidate paths, so the audit trail does not become a copy of the user's directory layout. The scanner reads no configuration at all — its bounds are its own defaults, which is what keeps a scan a function of the filesystem and the root alone. The shipped surface today is the project-scaffolder App Store app page (off by default); the two endpoints are deliberately the repackaging boundary, so a CLI, a core folder-UI action, or another delivery shape would call these rather than re-implement the scan.
Agents: GET /api/agents (Kiro Crew agent roster ordered most-used-first — config agents plus, scoped to the requesting session's project directory via X-Session-Key, that project's .kiro/agents discoveries, each row tagged scope: "global" | "project"; a name in both scopes lists once as the alias, since dispatch resolves aliases first; ordering reorders by ConversationLog.agent_usage() (turn count, then recency), falling back gracefully to config-insertion order on any failure so the dropdown never breaks or drops agents), GET /api/agents/installed (list all kiro-cli agents from ~/.kiro/agents/ — deliberately global-only, because every consumer is an agent CRUD/editor surface whose "Set as default" persists the selected name into cfg.agents, where a project-only name would not resolve; project-scope discovery instead reaches the dispatch surfaces — per-turn resolution, spawn validation, and Slack — with scope on each entry reporting which scope won there; package field extracted from filename), GET/DELETE /api/agents/detail/{name} (full agent config JSON; DELETE removes the config file, protected for kirocrew/kirocrew-lite; DELETE and PATCH are owner-only — see Agent Config)
Capability Integration (edition-supplied operations-based CapabilityManager seam — the edition owns its CLI grammar, output parsing, and error translation; on a vanilla OSS install CapabilityManager.available() is False so every endpoint returns HTTP 503 "capability manager not available"; every POST here is owner-only — see Agent Config): GET /api/capability/mcp (list installed MCP servers), POST /api/capability/mcp/install (install an MCP server, pushes refresh("agents")), POST /api/capability/mcp/uninstall (pushes refresh("agents")), GET /api/capability/mcp/registry (browse available MCP servers — the manager returns already-parsed entries, which the core passes through as {"servers": [...]}), GET /api/capability/skills (list installed skill packages), POST /api/capability/skills/install (install by package only — no version_set; the manager owns version/source resolution; regenerates agent config, pushes refresh("agents"); the manager returns human-friendly errors), POST /api/capability/skills/uninstall (pushes refresh("agents")), GET /api/capability/agents (list installed agent packages), POST /api/capability/agents/install + POST /api/capability/agents/uninstall (by package only, same no-version_set rule as skills; both rebuild the agent config off the event loop, clear the list_agents() cache — it keys on a stat-only per-file (name, mtime-ns) signature, so a rename invalidates but a same-tick in-place mutation would not bump it — and push refresh("agents")), GET /api/capability/plugins (installed client-plugin packages plus out_of_sync, the drift set of packages installed as agents but missing their plugin counterpart; both reads are asyncio.gathered so the endpoint stays inside ONE CAPABILITY_READ_TIMEOUT — it is polled), POST /api/capability/plugins/sync (reconcile that drift). Package names are allowlisted (_is_valid_capability_package: length cap, .. rejection, charset) before crossing into the edition manager, manager messages are _redact_external-scrubbed + length-bounded on BOTH success and failure paths, and each mutation emits an explicit SEL line naming the package (the audit middleware logs only the request path). The routes were renamed from the former /api/aim/* to neutral /api/capability/* vocab so no Amazon-internal name fossilizes in the fork's public API.
No cross-provider bridge. There are no /api/cc/* routes: Kiro Crew drives one provider surface, and provider-specific MCP config is handled uniformly through the extra_mcp_scopes() seam (see platform-context.md) — the core manages the Kiro global only, and a companion re-adds its own provider scope for apply, uninstall AND discovery. On /api/mcp/apply the omitted-field default differs by scope family: the core kiroGlobal key is omit → delete (the bundled SPA always sends it) while every seam f"{id}Global" key is omit → preserve (defaults to current on-disk presence), so an OSS apply that omits a companion scope never deletes that provider's server; see the extra_mcp_scopes() contract note in platform-context.md.
Session sidebar ordering: pinned sessions form a manually ordered section ahead of the automatically sorted remainder. The browser persists that order in mc-pinned-session-order, removes stale or duplicate keys, and appends newly pinned sessions in the selected sort's natural order. A pinned row dropped on a pinned peer in the same rendered container changes only that manual order; folder, root, status-column, and chat-reference drops retain their existing meanings. Focused pinned rows also move within their rendered container with Alt+ArrowUp or Alt+ArrowDown. A quiet double-line divider appears only when a container has both pinned and unpinned rows, and explicit search results remain relevance-ranked without the divider. Session-row layout projection is limited to the first 48 rendered paint positions (roughly two viewports) at every list size; later rows remain interactive but membership changes snap instead of joining Framer measurement and springs. Session lane scroll memory (useLaneScrollMemory): collapsing the sessions sidebar or closing the mobile drawer unmounts ChatSidebar, so the flat and tree lanes each remember the session row at their top edge plus its offset (in memory only, per lane kind) and scroll that row back into place on the next mount, correcting over a few frames while content-visibility: auto placeholder rows resolve; any wheel, touch, pointer or key input on the lane ends the correction, a closed anchor row falls back to the recorded pixel offset, board columns are not covered, and a page reload starts at the top.
Sessions: GET /api/sessions (paginated list; opt-in exclude_open=1 drops every session a live slot already holds open — resolved through slot_history_key/slot_transcript_key so channel tabs whose transcript is their linked_session_key are recognised, and applied BEFORE total/has_more so one page's arithmetic matches what it returned. The sidebar's Older-sessions pane is the only caller: it renders the complement of the tab list above it, while the full inventory stays the default for memory consolidation and the command palette's recents. The same predicate protects a session from DELETE /api/sessions), GET /api/sessions/{key} (detail), DELETE /api/sessions/{key} (permanent delete), GET /api/sessions/usage (kiro credit usage, cached 10 min; the background refresh tries the real CodeWhisperer RTS GetUsageLimits API first — the true used/limit/overage — posting to a hardcoded regional host keyed off the whoami profile ARN (codewhisperer.us-east-1.amazonaws.com by default, q.eu-central-1.amazonaws.com for eu-central-1; any other/missing region falls back to us-east-1); reading bearer tokens from kiro-cli's own SQLite auth store under keys kirocli:odic:token (OIDC/Builder ID), codewhisperer:odic:token (legacy), kirocli:social:token (GitHub/Google social login), and kirocli:external-idp:token (Identity Center/org SSO), then falls back to scraping kiro-cli chat --no-interactive --agent kirocrew-lite /usage stdout when the API path is unavailable and dashboard.usage_text_scrape_enabled is true — reachable at Settings > Display > View, since the key is in the config PATCH allowlist, and a flip needs no restart because the gate calls KiroCrewConfig.load() per check and the config cache is keyed on a stat fingerprint any edit busts, so the next refresh interval sees the new value; the API path does NOT refresh the token it reads, because that store is kiro-cli's to renew — an OPERATIONAL dependency, not just an implementation note, since on an install where the sign-in is owned elsewhere and kiro-cli is never driven the stored token lapses with nothing renewing it, so fetch_usage_limits returns an auth_state naming an auth-class failure (no_credential when nothing unexpired was readable, rejected on a 401 from GetUsageLimits; a 403 is an entitlement problem and is deliberately NOT counted) and the unavailable payload then carries reason: "signin_required" instead of reason: "scrape_disabled" — the latter asserts the account has no credit plan and points at a knob that spends credits on a chat turn needing the same lapsed sign-in. The scrape decision itself is unchanged by that classification, because an empty candidate list does not prove kiro-cli cannot authenticate: it may authenticate from a store this reader does not enumerate), POST /api/sessions/summarize (one-line LLM summaries for a list of session keys — bounded to 8, generated on an ephemeral background session with the cheap Haiku model, best-effort; backs the list_sessions MCP tool's opt-in summarize=true), GET /api/sessions/memory (per-session and per-task memory footprint; returns {sessions, tasks, totals, history} — each session row carries: key, title, slot_key, untitled, agent, channel (the grouping dimension, resolved by telemetry_channel_of(key) from kiro_crew.messaging.link — bounded cardinality, same taxonomy as telemetry metrics), pid, owns_runtime, rss_mb, procs, mcp, cpu_cores, prompts, uptime_s, credits (cumulative kiro credits consumed over the spend window — SPEND_WINDOW_DAYS in dashboard/handlers/usage.py, which the Telemetry spend tab's cost_breakdown shares so the two surfaces cannot report different totals for one session; null when no measured turn exists in the window, which is semantically distinct from zero; the join is by session key, and because a slot bound to a channel or cron conversation runs its turns under linked_session_key while its usage rows are still filed under the dashboard slot.key, the lookup falls back to DashboardState.spend_slot_by_session() — without that reverse index those sessions report null despite having spent), turns (count of completed turns in the same window — null same semantics as credits), parent (the session that opened this one through session_create, as the child's own crew log records it — null for a session nobody created, else {slot, key} where key is the creator's LIVE session key when it is running, the edge the Sessions table nests on, and null otherwise; folded by crew_log/session_tree.py, see crew-log-projection.md section 6); totals carries rss_mb, host_mb, host_pct, runtimes, rss_is_upper_bound, lineage_over_cap (whether the store held more session logs than the capped lineage scan admits on this sample; the live sessions' logs are read first, so what went unread is closed sessions' logs; false within the cap or with the crew log off), lineage_cap (that cap, so the page can say "N+"; 0 with the crew log off); history is a ring of {t, mb} snapshots; tasks is the subagent task list from SubagentManager.task_memory_rows())
Logs: GET /api/logs (SSE), GET/POST /api/logs/level (runtime log level control)
Task Runner: GET /api/taskrunner (status with runs[], includes agent), POST /api/taskrunner (start, optional agent field), POST /api/taskrunner/cancel (per-task or all), DELETE /api/taskrunner/{task_id} (remove finished run), POST /api/taskrunner/refine (dynamic multi-turn with tool access), GET /api/taskrunner/refine (status with waiting field), POST /api/taskrunner/refine/cancel, POST /api/taskrunner/refine/answer (answer clarifying question)
Approvals: GET /api/approvals (pending list), POST /api/approvals/{id}/approve, POST /api/approvals/{id}/reject
Agent questions (legacy blocking stack — retained in code but no longer used by the now non-blocking ask_question; see "Agent Questions" above; slated for removal): POST /api/ask-question (blocks until answered; body {session_key, questions, timeout_secs?}; returns {status: "answered", ask_id, answers} or {status: "timeout", ask_id}; 404 on unknown slot so a caller never blocks on a card nobody renders, 400 on invalid payload), POST /api/ask-question/{ask_id}/answer (body {answers} or {dismissed: true}; 404 once the question has been answered or has expired), GET /api/ask-question/pending ([{ask_id, slot, questions, ts}] — rehydration source, since question_card is a one-shot broadcast: without it a reload or WS reconnect leaves the agent blocked with no card on screen until its window elapses; the frontend re-syncs it on WS open exactly as it re-syncs GET /api/approvals). All three are owner-only, and denying app tokens alone is not sufficient: a dashboard session token is also minted for every allowed Slack user (!dashboard) and carries an empty app claim, so it clears the app gate while belonging to a non-owner who could otherwise address a card at any slot (phishing the owner with crafted options, then reading the typed answer out of its own blocked response) or resolve a card the owner is still looking at. is_owner_dashboard_request is reused rather than re-derived, so "owner" has one definition — an exact owner_id match, or a signed local-app/local-startup bootstrap subject when no owner is configured, which is the identity the ask_question MCP tool itself carries since its token is minted as generate_token(owner_id or "local-app"). The WS side is owner-scoped too: DashboardState.broadcast_ws_owners sends question_card and question_card_resolved to _owner_ws_clients only. Gating the endpoints alone would be cosmetic — a non-owner dashboard session registers as an ordinary WS client, so a plain broadcast_ws would deliver the owner's question text and options to it regardless.
Reveal: POST /api/reveal (body {path, action?} where action is "open" or "reveal", default "reveal"). Sensitive paths are denied (403). A direct-local-request gate (is_direct_local_request) restricts native openers to loopback callers with no forwarding headers; remote/tunneled callers receive {ok: true, copy: "<path>"} instead — the shared revealOrOpen helper writes the path to the clipboard silently (the affordance that routed there already promised a copy), with no rendered confirmation. GET /api/dashboard/branding exposes direct_local: boolean so the UI can conditionally render Open/Reveal affordances.
Terminal: POST /api/terminal/sessions (create PTY session, returns {session_id, shell}; capped at dashboard.terminal.max_sessions). The shell every spawn site launches resolves through one helper (_resolve_shell): the configured dashboard.terminal.shell (Settings → Display → Terminal; declared in the config schema, editable via the config PATCH allowlist with an off-loop save-time executable check whose 400 carries code: "shell_not_executable"), else $SHELL (POSIX), else the platform default (/bin/bash / powershell.exe) — each candidate validated with shutil.which and pinned to the RESOLVED absolute path (a bare name re-resolved in the spawn's project cwd could differ), so a configured value that no longer resolves falls back rather than failing the open. A rejected configured shell is surfaced at the API level — the create response adds shell_fallback: true plus configured_shell, and each spawn site logs a warning; the dashboard panel itself spawns via the WS handler and does not read those fields, so the user-visible typo surface is the save-time Settings validation, GET /api/terminal/sessions (list), DELETE /api/terminal/sessions/{session_id} (kill), /api/ws/terminal/{sessionId} (per-session WebSocket: binary frames carry raw PTY I/O forwarded BYTE-FOR-BYTE — the server does not decode, scan or rewrite the stream at all. Scanning it was removed. The panel renders into the authenticated operator's own browser, showing bytes their own shell just wrote, so any threat that reaches it reaches the terminal app beside it and the scan protected nothing the user could not already see; meanwhile it cost real correctness (hiding a token printed on purpose via gh auth token, swallowing a device-code login or presigned URL mid-flow, mis-firing on high-entropy build output such as an npm integrity sha512-… line) and it forced a decode whose read boundaries corrupted output: a PTY read ends wherever the kernel had bytes, so a multi-byte character split across two reads became two U+FFFD, permanently destroying CJK, emoji and a TUI's box-drawing glyphs with no way for the client to recover the original bytes. Forwarding bytes moves that reassembly to xterm.js, which runs its own incremental decoder, so no read boundary can corrupt a character and the server holds no decoder state that could desynchronize from the client's. The reconnect replay is the same byte copy (bytes(sess.scrollback)) — a truncated ring buffer can begin mid-character and the client renders that head as it finds it, the lead byte being genuinely gone. The credential boundary is POST /api/terminal/redact below, which is where output reaches a model; a source guard (test_redactors_are_called_only_by_the_selection_handler) pins the redactors to that single call site so a scan cannot reappear on the streaming path, and an integration test drives the real loop with an unbroken 9 KB run of 3-byte characters — one line, no newline, because a shell that writes line by line hands the reader whole lines and every read then lands on a character boundary by accident, which is how an earlier version of that test passed against the corrupting code. Every send goes through a WebSocket captured into a LOCAL and revalidated after the lock await, never sess.ws directly — the WS handler sets that field to None on disconnect, and AttributeError is not caught by the loop's except OSError, so touching it after a suspension point would kill the reader task and stop PTY draining and scrollback capture for a session the client may still reconnect to (a source guard pins sess.ws.send out of the module). A second source guard (test_session_reservation_has_no_await) pins the reservation region await-free: the max-sessions check and the placeholder assignment together are the reservation (the handler marks the boundary with its own "Reserve slot synchronously before any await" comment), and an await in there lets two concurrent opens for one session id both pass the check and both spawn a PTY, leaking one untracked; it deliberately starts at that comment rather than at registry.get, because the stale-entry cleanup above it awaits _kill_session and is pre-existing. JSON control frames are client→server resize {cols, rows} and server→client title {text} (foreground command name while one runs, else cwd basename), cwd {path} (the shell's full live working directory), error {message}, pong). A singleton poller (poll_terminal_titles, ~1s) probes each connected session off-loop for title and cwd, pushing each frame only on change; cwd resolution reads /proc/<pid>/cwd on Linux and falls back to lsof -d cwd on macOS/BSD executed ONLY from trusted absolute paths (/usr/sbin/lsof, /usr/bin/lsof — never PATH resolution), failing closed when absent. The poller captures and revalidates the session's WebSocket after every executor hop so a mid-probe disconnect can never crash it, and reattaching a WebSocket resets the per-session title/cwd dedup markers so reconnected clients are re-pushed current values. POST /api/terminal/redact (body {text}, 256 KiB cap → 413) scans a COMPLETE selection before the frontend inserts it into the chat composer, and is the WHOLE credential boundary for the web terminal: the live stream is forwarded unscanned and the scrollback is replayed only to the browser, so this is the one path by which terminal output reaches a model. It therefore runs unconditionally, with no configuration that skips it, and a contiguous selection is also the only input the regex redactors can be accurate on — a secret split across two 4096-byte reads is invisible to a per-chunk scan by construction; the frontend fails closed (no insertion, visible retry state) unless it returns 200. The selection toolbar (frontend CliPanel.tsx) appears on text highlight with Send to chat (appends the redacted selection to the composer draft annotated Terminal output (path): — live cwd frame value preferred, spawn dir fallback — wrapped in a backtick-run-escaping code fence; never overwrites the draft, never auto-sends) and Copy (confirms only after navigator.clipboard.writeText resolves, with an explicit failure state). POST /api/terminal/complete (body {session_id, token, folders_only?}, where token is the DEQUOTED literal path the cursor sits in — "../Kiro", "src/", "", and my dir/ for an on-screen my\ dir/, since the client decodes backslash escapes before asking) returns {dir, prefix, entries: [{name, dir, at}], truncated} and backs the panel's inline path completion: a word longer than _COMPLETE_TOKEN_MAX (4096) is 413, an unparsable body or a non-string session_id/token 400, an unauthenticated caller 401, the feature flag off 403, and an unknown session id 404; SEL audit event: terminal.complete, emitted for EVERY outcome including success, with a fixed reason word as its only resource (feature_disabled, invalid_body, token_too_long, unknown_session, no_cwd, sensitive_path, listed, plus the command tier's invalid_argv, cmd_unknown, cmd_none, cmd_listed, plus completion_disabled) — deliberately coarse, because the route fires per keystroke and the token, prefix, resolved directory and entry names are all user filesystem contents that would turn the audit trail into a transcript of the user's typing and disk layout. The session's working directory is probed per request (_session_cwd_cached, a ~0.4 s TTL memo run off-loop) rather than reused from the ~1 s title poller's last_cwd — a completion issued immediately after a cd would otherwise resolve against the previous directory — while the memo keeps a held-down key from spawning an lsof per keystroke on macOS. The token's directory part resolves against that cwd with a leading ~ expanded, so dir is the absolute directory actually listed; when the cwd is unknowable (non-POSIX host, or the probe failed) the answer is a well-formed empty one with dir: null as the "nothing resolved" signal rather than an error the client must special-case. Matching inside that directory is a case-insensitive substring search, so a long name is reachable by its distinctive middle (termi → KiroCrew-terminal-completion), and each entry's at is the offset the fragment matched at, which both ranks the results (earliest match first, so a true prefix still wins; then directories, then name) and lets the client highlight the span. A fragment that starts with a dot is matched as a prefix instead, because there the dot is what unhides hidden entries rather than a distinctive part of a name — substring-matching it would pull in every foo.bar and defeat the filter it just switched on. Hidden entries appear only once that leading dot is typed, folders_only drops plain files (is_dir follows symlinks, as the shell does), entries whose name contains a C0/DEL/C1 control character or a lone surrogate are filtered at the source (a surrogate survives JSON but TextEncoder turns it into U+FFFD, so the client would type a path that does not exist) (the client TYPES an accepted completion into the PTY, so a name holding CR/LF would submit an executed command line and an ESC would inject an escape sequence — no escaping makes those safe to type), and a missing or unreadable directory yields an empty list rather than an error — at keystroke rate an unreadable path simply has no completions. Two independent caps bound the work: candidates are pulled lazily into a heapq.nsmallest of _COMPLETE_MAX_ENTRIES (200) so a directory with 100k entries is never materialized, and _COMPLETE_MAX_SCAN (20000) bounds how many entries are EXAMINED, since retention alone would still walk a million-entry directory while holding a pool thread; truncated is true for either cap. The target directory is vetted through hooks.validate_file_path — the named chokepoint the backend security rules require every file read to pass through, which canonicalizes with realpath and then refuses via is_sensitive_path() — before any scan (_vetted_completion_dir) — canonicalization comes FIRST because a benign-named symlink, or a symlinked parent component, whose target lands inside the governance trust-root would otherwise pass a name-based check and be enumerated through the link; a refusal (like a realpath failure) returns the same well-formed empty answer, disclosing nothing about whether the path exists. The enumeration is then PINNED TO A DESCRIPTOR (_open_vetted_dir opens the vetted directory O_RDONLY|O_DIRECTORY|O_NOFOLLOW and os.scandir is handed that fd, never the path string) because vetting a name and scanning that name are two resolutions of the same string: swapping the directory for a symlink to ~/.ssh in between would otherwise enumerate the target. The open is itself verified — the fd's (st_dev, st_ino) must equal what the vetted name resolves to, so a swap in that remaining window fails closed — and the descriptor is closed on every path out, since os.scandir(fd) does not take ownership of it. The scan runs on discovery_executor(), not the subprocess_executor() shared with PTY teardown, so a slow directory cannot starve the os.close of session teardown, which can itself wedge in the kernel. The authority model is the live session id: the route lists a directory on behalf of an authenticated caller who already owns a PTY in this gateway, i.e. an interactive shell with the same filesystem access, so it grants nothing that session's own ls does not — which is what keeps it from being a general filesystem-enumeration endpoint, and why paths resolve without a root restriction, exactly as the shell would. Command tier (dashboard/terminal_commands.py): the same route answers SUBCOMMAND and FLAG completions when the body carries argv ({session_id, token, argv: ["gh", "pr"]}), returning entries shaped {name, desc, kind: "sub"|"flag", nospace?} with dir: null — the per-entry kind is what lets a client tell a command answer from a path one, so the path tier's top-level response shape is unchanged and the two tiers never fall back into one another (the client picks, because only the client can see the screen row). A malformed argv is 400 (invalid_argv): it must be a non-empty list of at most ARGV_MAX_WORDS (24) strings of at most ARGV_MAX_WORD_LEN (256) characters, none containing a control character, DEL, C1 or lone surrogate, whose FIRST element matches ^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$ — a bare name, never a path. This branch is ordered BEFORE the unknown-cwd refusal, because a subcommand list does not depend on the working directory. The authority argument is narrower than the path tier's and is what the whole tier rests on: it runs only what pressing Tab in that same shell already runs. The entire body of a cobra-generated completion script is a call to <tool> __complete <argv…> <word> (value\tdescription lines then a :<directive> line; the ERROR bit 1 is the only reliable failure signal since cobra exits 0 regardless, and the NoSpace bit 2 rides through to nospace), and git's is git --list-cmds=list-mainporcelain,others,list-complete,alias for subcommands — chosen over --help scraping because git's --help advertises only ~22 of ~150 completable commands and a subcommand's --help renders a man page. Git subcommand FLAGS are deliberately NOT offered, and that removal is the one place this tier's authority claim did not hold: the obvious probe, git <sub> --git-completion-helper, reaches a builtin's parse-options only when <sub> IS a builtin, whereas for an alias git expands it first and a !-prefixed alias body is then EXECUTED via sh -c — so git wipe --⎸ under alias.wipe = "!git reset --hard && git clean -xfd" would have destroyed the session's working tree, which the strict sandbox does not protect (it hides credentials, not the repository). Git's own completion script RESOLVES an alias through config lookup rather than invoking it, so Tab would never have run that body while this probe would have; _probe_argv therefore returns None for the git flag case and no probe is made at all. A guarded form is conceivable (probe only when <sub> appears in git --list-cmds=builtins, since git ignores aliases that shadow builtins) but was rejected as the wrong bargain inside a security boundary. Those two protocols are all that is ever executed. A probe is additionally REFUSED outright when any word on the line would point the tool at a different endpoint or identity (_REDIRECTING_FLAG_STEMS — --server, --host/-H, --hostname, --kubeconfig, --context, --token, --user, --as, the TLS/cert/proxy family, matched on the stem before any =; a one-dash word is parsed as a flag CLUSTER rather than a stem, since -shttp://host is kubectl's short --server with an attached value and an exact stem match read it as the stem shttp://host and let it through): a cobra completer at a leaf position makes a live request, and those flags choose WHERE it goes and WHO as, so kubectl --server=<url> get pod ⎸ would otherwise have a keystroke send an unsolicited request to an arbitrary host with the gateway as the client. The list is deliberately broad — a false refusal costs one menu, a miss costs an outbound request. Five controls keep that claim true: an allowlist, not discovery (_KNOWN, extensible via dashboard.terminal.completion.commands, whose values are ignored unless they name an implemented protocol) — speculatively running <whatever was typed> __complete is what would make this dangerous, since make __complete builds a target called __complete; bare names resolved against a sanitized PATH — three classes are dropped: every empty and relative entry; every project-local tool directory (.venv/bin, node_modules/.bin, .tox, vendor, matched segment-wise on BOTH separators so an installed prefix like /opt/venv-tools/bin survives and so the filter cannot silently stop matching on a host whose os.sep differs); and everything a third party could write — every component of the canonical path must be owned by uid 0 or by the uid this gateway runs as, never world-writable, and group-writable only for an administrator group (_ADMIN_WRITE_GIDS, gid 80 admin on macOS only, whose members can already sudo; no gid qualifies on Linux, where root/wheel membership does not imply sudo rights) — walking upward, because a trusted bin inside a parent someone else can write can simply be swapped (the same reasoning sudo's secure-path handling applies). A gateway running as root gets no group-write exemption at all: its children are root too, so admitting a group-writable node would let a merely-admin account substitute a binary that then executes with root privileges. The predicate is deliberately ownership+mode rather than os.access(W_OK): the latter answers "can THIS process write here", which is yes to everything when the gateway runs as root (routine in containers), so it would silently disable the tier on those hosts while looking like a hardening win. Requiring a ROOT-owned chain is not tenable on a developer machine — Homebrew installs into a prefix owned by the installing user (/opt/homebrew, group admin), so uid-0-only leaves macOS with the handful of tools in /usr/bin and drops every one this tier exists for (gh, docker, kubectl), which is an unused code path rather than a security win; this is the policy github_runner.validate_provider_executable already applies to provider CLIs (accept st_uid in (0, uid), refuse other accounts, refuse world-writable, refuse the agent-writable trees), reached there for the same reason. The residual cost is stated plainly in _sanitized_path: the agent shares the gateway's uid, so a directory this filter keeps is one the agent could plant a binary in. Three things bound it — _KNOWN is closed, so a plant must SHADOW a specific real tool name and win PATH order; the plant does not choose the argv; and an agent that can write files already holds more reliable execution paths (~/.zshrc, a git hook, a LaunchAgent) that no PATH filter touches. The trees the agent most plausibly writes are refused by LOCATION regardless: _under_agent_writable_root drops any entry inside KIROCREW_PROJECT_DIR or workspace_root(), which is what covers a checkout's own bin/ or scripts/ — a name the segment filter cannot see, commonly PREPENDED to PATH by a .envrc or dev shell so it would win resolution, and the one substitution vector the model itself controls (the same trees github_runner.agent_writable_roots() refuses for provider CLIs). Root discovery FAILS CLOSED: when the workspace lookup raises or a root cannot be canonicalized, the root set is None and every PATH entry is refused (no completions, one warning) rather than the unresolved root being silently dropped, which would admit exactly the tree the filter exists for; membership compares against root + os.sep so a sibling like …/workspace-other is outside …/workspace. There is deliberately NO opt-in to widen this — an earlier revision offered an operator-declared trusted-directory list, and review was right that it reinstates the whole vector for anyone who uses it, since "the operator consented" does not make an agent-writable directory safe to execute from mid-keystroke. The resolved binary is held to every test its PATH entry was — name-based, location-based and ownership — since a symlink in an allowed prefix pointing somewhere writable would reintroduce what the filter removed and the target is the file that executes. Resolution FAILS CLOSED when nothing survives, because shutil.which(cmd, path=None) silently falls back to the unsanitized $PATH. The honest limit: ownership bounds who could have planted a binary, and cannot bound a same-uid actor, which no permission bit can. so a planted ./gh in the session's cwd can never be the file that runs; no shell ever (an argv list handed to execve, so a metacharacter in a half-typed word has nothing to escape into, and _probe_argv is separated from execution precisely so the exact command line is assertable without spawning); sandboxed as an agent-influenced spawn at the STRICTEST tier — routed through sandbox.sandboxed_spawn_argv(..., "strict") (every credential directory hidden, not merely the non-workflow ones the default standard tier covers, plus an ALLOWLISTED env — built from nothing rather than filtered down, because a denylist covers the credential names it knows and by construction cannot cover the ones it does not, so GH_TOKEN/KUBECONFIG/NPM_TOKEN and every future tool's variable would otherwise have reached a speculative child; the child gets only TERM=dumb, NO_COLOR, the pager vars, the locale, and the SANITIZED PATH, with no HOME at all — verified to cost nothing, since git --list-cmds still returns 65 commands and gh __complete pr "" still returns every subcommand with descriptions, those tables being compiled into the binary; one consequence is that git cannot read ~/.gitconfig, so user ALIASES no longer appear in the listing, which is coherent with removing the alias-executing flag probe) and spawned with create_subprocess_limited (resource limits), because the allowlist bounds WHICH program runs but not what an argument can ask it to do, and a terminal line is writable by the agent (the run-in-terminal affordance) as well as by the user — standard would leave the kube and ssh config dirs readable and let an argument aim the tool at them (kubectl --kubeconfig <kube config> config use-context ⎸), while a probe needs no credential at all to read a table that is static in the binary, so the strictest tier costs no correctness; test_spawn_audit.py is the gate that keeps this routed; the wrapper is built on discovery_executor() rather than inline because it writes a temp launcher/profile (mkstemp/os.write/os.close) and a slow filesystem would otherwise stall the gateway loop at keystroke rate — and on subprocess_executor() it could starve PTY teardown's os.close, the same reasoning the path tier's scan follows — with the cleanup unlink offloaded fire-and-forget for the same reason; and SandboxUnavailableError is CAUGHT rather than propagated, because the chokepoint fails closed where a host has no usable backend (an AppArmor-restricted userns is the common case) and an uncaught raise would surface as an HTTP 500 on a keystroke, whereas this tier's contract already makes "no completions" a normal answer — so an unbuildable sandbox yields no menu and, verifiably, no child process at all; bounded (stdin /dev/null so a prompting probe sees EOF instead of hanging, stderr discarded, stdout capped at PROBE_MAX_BYTES, a PROBE_TIMEOUT_S wall clock that kills the process TREE via start_new_session, and a module-wide PROBE_CONCURRENCY semaphore); and the working directory passed through the SAME hooks.validate_file_path chokepoint as the path tier — a session sitting somewhere the gateway will not READ is somewhere it will not RUN a program either, answered sensitive_path. Values are validated, not escaped (is_command_token: ^[^\W_][\w.+:@-]*$ for a subcommand, ^--?[^\W_][\w.-]*=?$ for a flag, enforced on both sides) — a subcommand is a token the TOOL defined from a closed vocabulary, so anything needing escaping is not a real one and is refused at the parser, which is both safer than smuggling it through as an escaped literal and correct on screen, since escaping would turn the tool's own --message= into --message\=. Latency comes from caching, not from a fast probe: the candidate list is a pure function of (binary realpath, st_mtime_ns, st_size, argv path, flags-or-subcommands), so one subprocess (~36 ms measured) serves a whole argv position and every later keystroke narrows the cached list in-process by case-insensitive PREFIX (not the path tier's substring search — a subcommand is short and typed from the front, so matching m inside comment would offer rows that look unrelated). Keying on binary identity rather than name expires a tool's listings when it is upgraded or a version manager repoints its shim; negative results are cached too (at a shorter TTL) so a failed probe is not re-paid per keystroke, and the LRU is bounded at CACHE_MAX_ENTRIES. Deliberately NOT offered: completion of the command NAME itself, which needs a PATH-wide executable scan with a different cost profile and its own disclosure question. Positional VALUES are not a target either, though the accurate statement is narrower than "excluded": at a leaf position a cobra tool answers a bare __complete from its own ValidArgsFunction, so those values arrive through the same wire format and, when shape-valid, render as subcommand rows — bounded by strict sandboxing (which starves the cluster- and account-backed completers of the credentials they need, so kubectl get pod ⎸ resolves to nothing rather than to a live API call from the gateway) and by is_command_token (which drops anything not shaped like a token, as most identifiers are not). Client side (TerminalCompletion.tsx + utils/terminalCompletion.ts, POSIX-only following the PTY backend): the word being completed is read back out of xterm's screen buffer on cursor movement, never mirrored from keystrokes, because a mirror drifts the moment the shell rewrites the line itself (zsh completion, autosuggestions, Ctrl-R, paste, vi mode) whereas the rendered row is by definition what the shell believes the line to be. The command word is located from an OSC 133;B or OSC 697 NewCmd/EndPrompt prompt marker when the user's shell integration publishes one, and from a last-prompt-terminator heuristic otherwise. completionMode then selects the tier, and the two are disjoint with PATH winning wherever it already applied, so no word that previously produced a menu can regress. The PATH tier keeps its two triggers — a path-shaped word (contains /, or starts with ~ or .) for any command, and a bare word only when the command is a known path command (ls ⎸ lists the cwd) — with flags and $/backtick starts never triggering it, and cd/pushd/mkdir/rmdir requesting folders_only. Everything the PATH tier refuses goes to the COMMAND tier when there is a command word, the word cannot be a path (no /, not ~-rooted), the command is not a known path command (so python ⎸ keeps listing the cwd rather than probing a tool with no protocol), and the word is not a $/backtick expansion — which is how a flag word gets completed at all, since the path tier rejects it outright. With no command word yet, neither tier applies. commandArgv builds the context from the same escape-aware word split as commandWord (one commandWords helper, so the two can never disagree about where the command starts), keeping flag words because a cobra tool's position in its own tree depends on them and decoding each word because argv entries are literal. An accepted value is typed under the rules of the tier that was ASKED for, never the tier a response claims: an entry whose kind does not match the requested tier is DROPPED rather than reinterpreted, and a command value that is not a protocol-shaped token is dropped too. That distinction is load-bearing for the ./ guard, which becomes a property of the entry's KIND rather than its spelling — a FILE named --force must be typed ./--force or the program reads it as an option, while the FLAG --force must be typed verbatim because ./--force names a path that does not exist. Accepting a subcommand appends a space and deliberately does NOT suppress the next empty word (the opposite of the file case), because re-opening one level deeper is how the tree is walked without typing; nospace suppresses the separator where the protocol says the value follows immediately. The menu deliberately produces NOTHING rather than something plausible-but-wrong whenever the row cannot be reasoned about: on the alternate screen buffer (vim/less/htop sweep the cursor over arbitrary text, a redrawn > cd ./src satisfies the prompt heuristic, and an open menu would then steal Escape/Enter/Tab/arrows from the TUI), on a wrapped row, or one with a non-single-width cell or a multi-code-unit (combining) cell at or before the cursor (translateToString returns one physical row while cursorX counts cells, so the two coordinate systems only agree otherwise), mid-word (the chosen name would be inserted in front of the surviving suffix), and for a word V1 cannot parse — one containing a quote character, or ending in an unfinished escape. Backslash-escaped words ARE handled: the tokenizer does not break at an escaped space and the word is decoded before the request, so a name accepted with escapes can still be walked into. Accepted text is backslash-escaped through a single choke point (buildInsertion) against an allowlist of shell-safe characters, so an unforeseen metacharacter is escaped by default, and a name that would be read as something other than a local path additionally gets a ./ prefix when the word has no directory part — a leading -/+, or a : anywhere — those leads make the argument an OPTION rather than a path and no escaping changes that (\-c is still -c, and vim +:!id executes id); backslashes rather than quotes keep the result ONE shell word with nothing left open, so the echo still tokenizes as one word and accepting a directory can re-trigger on its contents. Enter and Tab re-read the live word before acting and abort (returning the key to the shell untouched) if it no longer matches the word the suggestions were computed for — and for the COMMAND tier the identity includes the argv, not just the token, because gh pr c and git c share the token c, so a token-only check let a menu computed for one tool be accepted into the other's command line, and a failed request closes the menu rather than leaving the previous word's entries acceptable. Enter belongs to the shell until the user has arrowed onto a row: the menu opens unbidden on almost every word, so an Enter that accepted the top row by default rewrote commands the user had finished typing (git status became git stash) and every submit needed an Escape first. Enter now closes the menu and reaches the PTY untouched unless ↑/↓ was pressed on the CURRENT listing (navigated ref, reset when the menu closes and when a fresh listing replaces it, so arrowing and then typing on hands Enter back to the shell); Tab stays the no-arrow accept key (common prefix, else the highlighted row) — the convention of fish, zsh-autosuggestions and VS Code's terminal. Keys the menu claims (↑/↓, Tab, Escape, and Enter once a row has been arrowed onto) are cancelled at the DOM level, not merely by returning false from xterm's custom key handler: that only makes xterm return early without calling preventDefault, so Tab would still move focus out of the terminal and Enter would still fire keypress and reach the PTY as a CR, executing the line instead of completing it. All terminal endpoints require an authenticated caller and the feature flag (dashboard.terminal.enabled), and emit SEL API-access events. Completion has its own switch: dashboard.terminal.completion.enabled (default true; Settings → Display → Terminal → Command completion, a SettingsToggle on the config PATCH allowlist writing through the same per-path optimistic overlay as the shell field; declared as a nested properties node under dashboard.terminal so it flattens into SCHEMA_REGISTRY for the toggle's configKey, with completion left open via additionalProperties so the undeclared completion.commands allowlist still validates) silences the inline completion popup alone — both tiers, so the cd path menu goes quiet with the subcommand and flag menus — while the panel and its PTY keep working, which dashboard.terminal.enabled = false cannot do because it kills the whole terminal. It is read ONCE per request above the tier split, off the event loop on discovery_executor() alongside the completion.commands read it now shares, and deliberately NOT memoised in _is_enabled's 30s _enabled_cache, whose slot belongs to the whole-panel flag. A disabled completion answers the ordinary empty listing ({dir: null, prefix, entries: [], truncated: false}) rather than the 403 the panel flag returns — the client already renders no popup for that shape, so a configured silence needs no frontend change, and the outcome audits as ok/completion_disabled because nothing was refused. The gate sits AFTER the body, token-length and session checks, so a malformed request still gets its 400/413/404 rather than a spurious 200, and BEFORE the cwd probe, so a suppressed keystroke does no filesystem work. Both nesting levels are type-checked ("terminal": false, "completion": false) and a non-boolean enabled degrades to the default, since bool("false") is True and an HTTP 500 from a hand-edited typo would land on a per-keystroke route. Windows runs on ConPTY, not a refusal. There is no POSIX pty/fork there, so the platform_compat.IS_WINDOWS branch of api_terminal_ws spawns a ConPTY-backed shell (PowerShell by default, -NoLogo) through kiro_crew.conpty.WindowsPty, which drives the Win32 pseudo-console over ctypes with no extra dependency. A spawn failure drops the registry entry and audits terminal.ws.open with outcome=error and a conpty_spawn_failed= reason rather than refusing the platform up front. The child environment is the gateway's own (_pty_child_env), minus Kiro Crew's Python startup variables (PYTHONPATH/PYTHONHOME/PYTHONPYCACHEPREFIX, which would make a user's own venv import the gateway's site-packages) and plus BASH_SILENCE_DEPRECATION_WARNING=1. macOS ships Bash 3.2, which prints a three-line "the default interactive shell is now zsh" notice on every interactive start; inside this panel that is noise that opens every transcript, says nothing about Kiro Crew, and precedes the readiness marker every consumer of the stream must then skip. An existing value of any kind wins, so a user who wants the notice keeps it by exporting the variable empty, and the variable means nothing to other shells. Credential-bearing variables (SSH_AUTH_SOCK, the AWS set) deliberately survive: this is the user's own unsandboxed shell, and scrubbing them would break git-over-SSH and the AWS CLI in it. POSIX teardown order is load-bearing (_kill_session): end the child process group FIRST (platform_compat.kill_process_tree_async SIGTERM, a bounded 5 s proc.wait(), then SIGKILL), then await the reader task, and only then close the PTY controller fd. The read loop parks a pool thread in a blocking os.read() on that fd, and only ONE direction of this order frees it portably. Closing the fd does not reliably wake a blocking PTY read: Linux's tty layer hands the parked reader EIO, but macOS returns from that read only once the WORKER side hangs up, so a close-first teardown parks the thread for the process's lifetime. Ending the child is what produces that hangup, and it works on both kernels (EOF on macOS, EIO on Linux). The cost of the wrong order is not confined to one leaked thread: the parked reader is a non-daemon executor worker that interpreter shutdown joins, os.close on a controller fd with a reader still parked on it can itself block in the kernel, and a teardown stalled there never reaches the tree-kill, so the shell and its children survive as orphans. Closing last also removes the window in which the kernel could hand that fd NUMBER to an unrelated open() while a reader still holds it, the hazard _write_all guards with os.dup. A child wedged in uninterruptible sleep is the one case the kill cannot resolve, which is why the os.close stays offloaded to subprocess_executor(). Every wait in that kill is BOUNDED, including the one after SIGKILL (platform_compat.REAP_TIMEOUT_SECS), because SIGKILL is not the end of the story on a PTY: a shell blocked writing into a controller buffer nobody drains sits in a tty write inside the kernel, and the pending SIGKILL tears it down only once it leaves that sleep, which needs a READ on the controller fd. The read that would free it is this session's own reader task, so a session whose reader has already ended (an OSError on the fd, an EOF, a cancellation) hands an unbounded wait() a child that cannot exit until the close further down runs, and the close is behind the wait. An unbounded wait there is therefore a deadlock inside a request handler: the handler is lost for the life of the process, and on the shutdown path it wedges the whole teardown. On expiry the reap is abandoned with a warning naming the session and pid, and teardown continues, because closing the controller fd is itself what releases the wedged write and the child watcher reaps the exit afterwards. test_ends_child_before_closing_controller_fd, test_close_completes_with_a_reader_parked_on_a_real_pty and test_reap_after_sigkill_is_bounded pin those halves; the ConPTY branch keeps its own order because WindowsPty.read is not a PTY controller read.
Terminal mutation ownership: every terminal route is dashboard-owner-only (require_owner_dashboard_request, the shared is_owner_dashboard_request gate); an authenticated non-owner cannot list terminal IDs, create or delete a session, request completion or redaction, or upgrade the per-session WebSocket. Within that owner boundary, the current sess.ws identity is server-authoritative for client mutation. Binary input and resize confirm that identity under write_lock. Reconnect candidates serialize separately from PTY writes, replay the bounded scrollback, and catch output produced during replay through a monotonic byte count before owner publication: unlocked catch-up rounds keep PTY recording flowing to the current owner, and the last round sends under output_lock (output cannot advance while it is held) so a candidate always converges even against a continuously streaming PTY — a reload with no live owner can never be starved by tail -f. Replay and ready sends are bounded; a failed candidate, or one the bounded ring overtook while a live owner would see the gap, leaves the previous owner authoritative (with no live owner the ring's remaining bytes are delivered and the candidate published), while publishing a candidate cancels any blocked send to the displaced socket, rotates the transport lock, and — outside the output-publication lock — sends the displaced socket one coarse error frame (code: "displaced", no session id, no terminal content) and closes it. The bundled frontend (website/src/utils/terminalRegistry.ts) treats that code as a deliberate close: the session parks in disconnected — the banner names the cause (components.cliPanel.displaced_message, neutral icon, not the network-failure copy) — with no automatic redial (the online/visibility revive listeners skip it) until the user's Reconnect button re-arms it, so two live windows cannot displace each other in a loop; an error frame without the code, or a plain drop, keeps the ordinary backoff redial. Publication is cancellation-safe: a candidate whose handler is cancelled during that displaced-socket cleanup is detached again (identity-guarded, arming the orphan reaper) rather than left published as a dead owner. Error delivery and both candidate/displaced-socket cleanup are bounded so a backpressured transport cannot retain a handler or stall later reconnects indefinitely. Advisory title/cwd/pong frames go through _send_owner_control_frame, which captures the transport lock the socket was published with, re-confirms sess.ws identity after acquiring it, and bounds acquire-plus-send, so the singleton title poller can neither deliver to a displaced socket nor park behind a blocked one; the last_title/last_cwd dedup markers advance only after a confirmed send, so a timed-out or failed frame is retried on the next dirty tick instead of leaving chat handoff labelled with a stale cwd. A frame accepted before publication may finish. The first later input or resize from a displaced handler records one coarse SEL denial without terminal content and ends the handler, so one denial audit is the per-socket ceiling. A per-session reconnect credential is deliberately NOT part of this fence; it is the Phase 2 ownership protocol proposed in the terminal session ownership RFC (PR #7649).
Terminal input readiness: opening /api/ws/terminal/{sessionId} is transport readiness only; it is not permission for an automated caller to write a command. The additional server→client ready control frame marks input readiness. For Bash (the Linux/WSL Run-in-terminal target), the PTY is launched as a REAL login shell (-l) and inherits a PROMPT_COMMAND that emits a randomized OSC marker; the raw reader matches that marker across read boundaries without decoding or rewriting output and sends ready only after the profile chain returns. A login shell runs an inherited PROMPT_COMMAND after the profile files return and before its first prompt, which is why the signal can live there and still be a post-profile one; it also holds while a profile is blocked in a shell builtin such as read, where foreground-process-group inference would release too early. The marker CANNOT ride an injected --init-file instead, because Bash reads an rc file only for a NON-login shell, and a non-login shell is the regression in #5885: shopt -q login_shell is then false, so every profile stanza guarded on login-ness silently no-ops and the user's environment never loads (that option is read-only, so sourcing the same files from an rc file cannot substitute for it). The injected snippet is single-shot and self-removing — it emits only while its token variable is still set, unsets that token so neither a later prompt nor a child shell repeats the sequence, and withdraws itself from PROMPT_COMMAND only while that variable is still exactly the SCALAR that was exported — ${PROMPT_COMMAND[1]+x} separates a Bash 5.1 array whose element zero is still the hook, so a profile that appended keeps its own half either way, and the check avoids Bash 5.1-only syntax that macOS's /bin/bash 3.2 cannot parse. The mirror variable the ownership check compares against is unset on both branches whenever the hook runs. An operator who EXPORTED PROMPT_COMMAND into the gateway's own environment keeps it: the exported value is the hook followed by the inherited command (so it runs from the FIRST prompt, after the marker), the mirror is that whole value, and the withdrawal RESTORES the inherited command instead of unsetting the variable. Replacing it would be data loss rather than a lost nicety -- PROMPT_COMMAND='history -a' is the standard way to make concurrent shells append to HISTFILE, and without it an exiting shell overwrites that file with its own in-memory list, dropping what every sibling shell appended. A profile that ASSIGNS PROMPT_COMMAND outright drops the hook and with it the marker; that session's barrier never opens and the frontend's own bounded timeout reports the failure without writing, the same fail-closed outcome the barrier already has for a profile that never returns (tracked as #7657). One case is NOT fail-closed and the spec states it rather than implying the set is clean: a profile that installs its own hook only when the variable looks unset ([ -z "$PROMPT_COMMAND" ], which is how the RHEL-family /etc/bashrc installs its terminal-title updater, reached through the default ~/.bash_profile -> ~/.bashrc chain) sees the exported hook, skips its own install, and then the snippet withdraws — so that session ends with no prompt command at all, silently. No carrier choice avoids it: every hook a login shell inherits is visible to the profile chain, and the mechanisms invisible to it (BASH_ENV non-interactive only, ENV POSIX mode only, INPUTRC cannot execute commands) do not run at the post-profile point the marker needs. Also #7657. There is deliberately NO second release on inferred progress. Two were tried on #7641 and both could release while a profile was still reading input, which hands it the queued command: a timeout cannot tell a dropped hook from a slow profile, and inferring the prompt from the PTY's line discipline is fooled by a profile sitting in read -n 1 (clears ICANON) or read -s -n 1 (clears ICANON and ECHO, i.e. indistinguishable from readline at a prompt) -- where the command loses its first byte and the remainder executes corrupted, which is worse than not running. Configured POSIX shells without an injected protocol retain transport-ready behavior rather than becoming unusable, and a ConPTY session sends ready after its first shell output reaches the client. Reconnects to an initialized session replay scrollback before ready. The frontend keeps onTerminalReady waiters parked until that frame; its existing bounded timeout reports failure without sending when Bash initialization never completes.
File Picker: POST /api/upload (macOS only — opens native osascript file picker, returns absolute paths)
Screenshot: POST /api/screenshot (macOS only — screencapture -i, returns path to ~/.kiro/crew/screenshots/)
File diffs: GET /api/file-diff?path=... returns the working-tree diff and HEAD content for the requested file, plus a status field — not_git (the repository preflight failed, so the file is outside any git work tree and there is no baseline; only the preflight may claim this), untracked (inside a repo, all-added --no-index diff — including every file of a freshly-initialized repo with no commits yet), modified, clean, or error (a git invocation past the preflight failed or timed out; distinct from clean so a git failure is never reported as "no changes"). Every reachable status value is served with HTTP 200; a sensitive path is 403 {"error": "Access denied"}, and the empty-path / file-not-found early returns reply {"diff": "", "original": ""} with no status key. Snapshot reads and every Git subprocess decode text explicitly as UTF-8 with replacement for malformed bytes, so Windows locale code pages cannot corrupt non-ASCII file content.
Misc: /api/spawn, DELETE /api/spawn/{id}, POST /api/spawn/clear, /api/notifications, DELETE /api/notifications/{ts}, POST /api/notifications/clear, DELETE /api/sessions/clear, /api/update/check (git remote check), /api/update (fast-forward to the pinned upstream commit + rebuild + restart; refuses with 409 before touching the tree on a dirty tracked tree, a diverged checkout, an unresolvable or unreadable upstream (git_read_failed), or a revision whose requires-python the gateway's venv fails (python_floor, remedy in error); a merge that fails after acceptance ends the worker with an error progress step — see cli → "Dashboard Self-Update"), POST /api/update/channel (move a feed-checkable install onto another release lane — validates against the RELEASE_CHANNELS allowlist because the value becomes a feed-URL path segment, writes $KIROCREW_HOME/channel atomically, then re-checks against the new feed; 409 on a git checkout or an externally managed layout, which have no channel to switch), POST /api/restart (restart the gateway WITHOUT updating — how a wheel install picks up code a terminal-run installer already replaced on disk; valid on every layout, unlike /api/update, and replies before re-exec so the client can tell "restarting" from "failed")
Webhook Hooks: POST /api/hooks/agent is the shared external ingress endpoint, but credentials are first-class sources: every newly minted source owns {label, agent, enabled, require_signature} and one bearer credential, and maps to one operator-selected installed agent. Admission order is global switch → auth throttle → bearer lookup without stamping → per-source enabled gate (before body read; paused returns 503 source_disabled) → bounded raw-body read → HMAC/replay → last-used stamp → JSON/body validation → mapped-agent resolution → in-flight/capacity gates → background task. A body agent may be omitted or equal the mapping; a conflict returns 409 agent_conflict. A deleted/unknown mapped agent returns 409 destination_agent_unavailable and never falls back. Bearer lookup, source admission, and signing-secret lookup remain separate store reads so concurrent revocation fails closed; all store and agent-discovery reads are off the event loop. Dashboard management: POST /api/webhooks/tokens {label, agent, require_signature?}, PATCH /api/webhooks/tokens/{id} with the exact allowlist {agent?, enabled?, label?}, DELETE the same id. Signing policy and credentials are immutable after mint, preserving one-time bearer/signing-secret reveal. Legacy hooks.webhook_token is a synthetic read-only source; pre-routing stored rows normalize to agent: "", enabled: true and retain historical caller/default routing until explicitly assigned. The top-level Webhooks/Activity UI keeps source configuration separate from contexts/runs; the source rail contains no token table or operational rows. sessionKey must start with hook:, max concurrency is 6, sessions are destroyed after each turn, and registered context comes from ~/.kiro/crew/hooks.json.
MCP Custom Servers (manual JSON path — Add Custom modal + per-server Edit JSON): POST /api/mcp/custom {servers: {name: spec}, enable?: bool=false} — user-authored specs (stdio command/args/env XOR remote url/scopes/clientId/headers, unknown keys rejected by name; header names/values are shape-checked and a fresh spec carrying the redaction marker as a header value is refused — it means the user pasted a redacted read payload), validate-all-then-write (no partial batch), collision → 409 with conflicts list, servers land disabled unless enable: true (the modal's "Enable immediately" tick is the consent act). GET /api/mcp/custom/{name} — full editable spec including env (the list endpoint omits env; prefilling from it would drop vars on save). PUT /api/mcp/custom/{name} — replace spec, 404 when not Kiro Crew-managed, always preserves enabled/disabled state (editing is not consent to run; a pasted disabled: false cannot smuggle an enable). Non-allowlisted keys already on the entry (disabledTools, autoApprove, …) round-trip: an unmodified GET→PUT save succeeds and their on-disk values are preserved verbatim — they cannot be edited or removed via this endpoint (dropping disabledTools would silently widen the tool surface); modifying one → 400. headers straddles that line: an entry WITHOUT stored headers may author them on PUT like a fresh add, but once values exist on disk they join the carried set — reads redact header values, so an editable headers key would let the redaction markers overwrite the real credentials (modifying stored headers → 400 stored_headers_not_editable; changing them means remove + re-add, same as a url change with stored headers). Fresh POSTs carry nothing, so the tight allowlist still applies. All three SEL-audited; writes share the mcp.py file lock + entry helpers with discover-install. Consent-disabled entries (custom adds + registry installs) surface in the servers table as Disabled rows — list_servers() marks Kiro Crew-scope disabled: true entries (incl. when config sync mirrors the disable into the agent file) and a disabled server is never probed (a probe would spawn the unconsented process); the table's enable action is the reachable consent step. The refusal is enforced inside probe_server() — the one function every probe passes through, ahead of its local/remote dispatch — so a new per-server entry point cannot become a way around the consent gate by forgetting to pre-filter. probe_all() keeps its own disabled filter as defense-in-depth and to shape its result (disabled rows are omitted from GET /api/mcp/probe rather than returned with status="disabled"); a caller may add a friendlier error on top (Mochi's GET /api/apps/mochi/mcp-tools/{name} answers 409 server_disabled), but that is UX, not the safety property. The refusal deliberately does NOT write the probe cache: it is keyed by name and shared with GET /api/mcp, so recording an empty disabled result would erase the tool list an earlier real probe stored. GET /api/mcp rows carry kirocrewManaged (gates the Edit JSON action).
Frontend (React SPA)
In split view the geometric top-left pane stands in for the single-chat title
row at the surface's top-left: on desktop it clears the shell's sessions-sidebar
toggle while the sidebar is collapsed, on mobile it renders that toggle inline,
so the sessions list stays reachable from a split. Every split pane except the
focused one is dimmed by a background-coloured overlay at --pane-dim-opacity,
which fades as focus moves; a pane outside split view is never dimmed.
KiroPrerequisiteGate wraps the main dashboard route (the independent
/worlds-popout route is not gated). DashboardBootstrap mounts the proactive
auth-cookie refresh scheduler outside this gate, so a stale access cookie can
still refresh while the dashboard body is blocked. On a new gateway it:
- displays the connected gateway's OS so a remote browser does not imply the CLI is needed on the browser machine;
- links out to Kiro's official setup page (
OFFICIAL_INSTALL_DOCS_URL,https://kiro.dev/cli/) — a link, never a button; - once a viable CLI is found, names the commands the USER runs to sign in
(
KIRO_CLI_LOGIN_COMMAND,kiro-cli login, for a personal account, andKIRO_CLI_SSO_LOGIN_COMMAND,kiro-cli login --use-device-flow --license pro, for organization SSO) and offers Check again; - records first-run completion when
ready=true.
Kiro Crew performs neither setup step, and there is no code path that could.
Both belong to Kiro CLI. Deleted for install: the installer download
(https://cli.kiro.dev/install), its pinned SHA-256 pair, the bash/PowerShell
interpreter plan, POST /api/kiro-prerequisite/install, the _install
operation, can_auto_install, and the .kiro_cli_binary_trust.json
attestation. Deleted for sign-in: POST /api/kiro-prerequisite/login, the
_login/start_login device-flow spawn, extract_secure_login_url and its
trusted-host allowlist, the progress/ANSI scrubbers that rendered the CLI's
spinner, _capture_operation_output, can_login, and the whole
OperationStatus concept — with no operation left to run, the gate has no
progress UI. The payload still carries an operation key, but it is a
compatibility shim (legacy_idle_operation()), permanently idle: a dashboard
loaded before this change reads status.operation.status unconditionally in its
refetch-interval callback (the optional chain there guards status, not
operation), and that callback runs for every user, not only the first-run gate —
so a tab open across a gateway upgrade would throw on its next poll and drop to
the root error screen. It is served to owner and non-owner alike so the shape
never varies by caller, and can be deleted once no shipped client reads it.
The reasons are the same for both: the vendor's tooling does it better and stays correct as it changes, owning it here meant owning a privileged surface (a remote shell script executed unsandboxed; a credential-writing child process), and each copy on the Kiro Crew side was one more thing to drift — the installer's pin silently broke setup on any upstream change.
What remains is detection only. _run_process no longer accepts sandboxed=,
stdin_data=, or on_output=, so every spawn is one of two read-only probes
(--version, whoami), sandboxed, with a fixed argv, and nothing can be piped
into an interpreter or scraped out of a child's stdout.
TestKiroCrewNeverSetsUpKiroCli pins the absence of each symbol.
The sign-in card therefore shows the commands in <code> elements, rendered
verbatim from the backend-supplied login_command and sso_login_command. They
are code constants, not catalog values — a translated command cannot be typed
(see website/docs/i18n-catalog.md). Both tiers are labelled and offered
together because the browser portal kiro-cli login opens presents a free
Builder ID as a peer of organization SSO: a user on an SSO plan who picks the
wrong one authenticates successfully and only discovers the mismatch later, as
models missing from their account. Kiro Crew does not detect which tier applies —
that would mean inspecting the host's identity configuration — so the card
describes the choice and the user makes it. The card's only control is Check
again, which is also why the screen no longer carries a second Check again button
in its footer.
repair_required therefore has ONE producer now, the missing-agent-spec overlay.
A missing CLI is not a repair: the user obtains it from Kiro, so the gateway reports
installed=false and offers no action of its own rather than claiming a remedy it
does not have.
Any Kiro CLI that runs is directly usable for sign-in regardless of how it was installed (toolbox, Homebrew, winget, Kiro's installer, or a self-updated bundle). Trust is "the CLI runs, and it has a valid login" — install source, owner, and path do not gate setup or ACP launch — so an installed-but-signed-out CLI always offers an enabled Sign in to Kiro rather than a button-less "repair" dead end.
The first-run gate is the one screen that polls the HOST rather than the latch.
kiroPrerequisiteIsBlocking(status) is true only while the full-screen first-run
gate is the whole UI (not ready, not initial_setup_complete, owner). In that
state the gate polls every 5s AND passes ?refresh=1, because the two steps it is
waiting on — installing from kiro.dev and signing in, possibly from a terminal —
touch the gateway not at all, so a latch-reading poll could never observe either
and the gate would hold until the user pressed Check again. The cost is bounded to
exactly this screen: two short kiro-cli spawns per interval, ending the moment
ready flips, and a returning user never reaches it. This does NOT reintroduce
reauthentication chrome for an established install — that decision is unchanged
(see "The dashboard does not guide the user to sign in").
Ready dashboards continue prerequisite polling every 30 seconds, but that poll
is a free read of the gateway's latched state — it spawns no kiro-cli.
Only api.kiroPrerequisite(true) (?refresh=1), wired to the gate's Refresh /
Check again buttons via a one-shot forceProbe ref, asks the host to re-probe.
A later sign-out is therefore surfaced by the failing turn, not by the poll:
chat_runner's AcpAuthRequired handler puts the actionable kiro-cli login
message straight into the transcript.
The dashboard does not guide the user to sign in. There is no reauthentication banner, no "Sessions paused" state, no disabled composer or session-creation control, and no readiness context the chat surfaces subscribe to. Latched readiness is only refreshed at boot and on explicit request, so it is never fresh enough to disable UI on: a user who signed in from a terminal would sit behind a dead input box, and a persistent banner would nag every surface — including ones that never start a session — about a state the dashboard cannot keep current. The in-context error card is the whole mechanism instead: it appears only when the user actually tries to use the agent, and it explains itself.
Ordinary send paths (main chat, prompt optimization, side turns, and
POST /api/chat/slots/{slot}/continue) therefore never reject
on readiness — a stale latch must not
block a send the CLI would serve, and they mutate nothing before the turn, so a
failed turn costs only an error card. A queued successor turn runs rather than
parking on readiness.
continue belongs in that list because it IS a send: it queues one synthetic
continuation and lets the runner dispatch it, so the ACP attempt is its authority
exactly as for a typed message. Gating it produced the sharpest form of the
stuck case this section describes — the latch behind
verified_ready is refreshed by re-probing kiro-cli, and a probe that merely
TIMES OUT is indistinguishable from signed-out, so on a host where the
--version probe runs slow the Continue button was refused with a 503 forever
while typing the same request by hand still worked. test_not_readiness_gated
pins it.
The destructive reruns are the exception and still fail closed. regenerate,
edit-resend, and rewind truncate slot.messages and persist the result
(_save_slot_to_history, _pending_rewrite) before dispatching the background
turn, so "let the ACP attempt be the authority" does not hold for them: by the
time the turn raises AcpAuthRequired the history is already rewritten and no
error card can undo it. All three therefore call reject_if_kiro_unverified
BEFORE any mutation, returning the shared kiro_prerequisite_required 503.
(switch-variant is exempt — it swaps an already-stored variant and starts no
turn.)
POST /v1/chat/completions also fails closed, for a different reason: it has
no transcript the caller reads. Its collectors pick up only chunk/assistant
roles, so the error card an AcpAuthRequired turn appends is invisible and the
request would return HTTP 200 with empty content — an OpenAI SDK client
cannot distinguish that from a model that legitimately said nothing. It returns
the kiro_prerequisite_required 503 in OpenAI error shape until the endpoint
learns to translate AcpAuthRequired itself.
An unresolved check is never rendered as "setup required." The cold probe
spawns two sandboxed kiro-cli subprocesses (--version, then whoami), which
takes long enough to read; rendering first-run setup chrome across that window
flashed the full setup screen at returning users, who then watched it disappear.
Two layers close that window:
- The dashboard never waits on the check. Kiro readiness gates nothing in the
dashboard, so an unresolved check mounts the app immediately and fully
usable (
statusQuery.isPending→ children rendered directly). Whichever way it resolves, the user is already where they need to be: only a confirmed first-run status shows setup chrome; a signed-out established install shows nothing at all./api/readylikewise does not gate on Kiro state — that would only delay first paint (and would not do what it appears to: the desktop splash polls/api/statusand accepts any status< 500).warm_up()stays fire-and-forget and failure-contained, and its task is cancelled by the service shutdown hook; it yields_WARM_UP_DELAY_SECSfirst because itskiro-clispawn racing the concurrent app-backend spawns measurably lengthened and destabilized boot (~2.7s → 2.8-5.6s on one real home). The delay is injectable so tests need not sleep it. - First-run completion is known without probing — derived from the data home
at construction (
initial_setup_complete) and echoed by the probe-failure backstop, so a failed probe cannot demote a returning user to first-run. The SPA also remembers completion locally (kirocrew:kiro-setup-complete) so a COLD load with an empty query cache can still tell a returning user from a genuine first run. That memory only ever suppresses first-run chrome — it never grants session readiness, which stays server-driven viaready.
Accordingly, a status-check failure for a user who has completed setup leaves the
dashboard mounted and fully usable with no banner at all — an unreachable
status check is not evidence the CLI is broken, and the turn reports the truth
either way. A genuine first run — nothing remembered, no established home — still
gets the full setup gate, and initial_setup_complete short-circuits ahead of the
non-owner branch so an established non-owner is never shown the owner-restore
screen either.
Boot latency: app backends start in parallel. start_enabled_app_backends
vets apps serially (cheap) but spawns the admitted set concurrently
(_start_backends_concurrently, capped at _BOOT_SPAWN_MAX_WORKERS), and each
spawn's post-fork survival check (_survived_spawn) ends as soon as our own
child owns the listening socket rather than always sleeping its full ~1.6s grace
window. Previously both costs were paid serially per app, so gateway boot grew by
~1.6s for every installed app; measured on one real data home, boot to
/api/ready went from ~5.6s to ~2.9s. Three invariants make this safe:
- Ports are claimed before the bind, under the lock — auto ports via
_reserve_free_port, and a fixed manifest port via_claim_port. Concurrent select-then-spawn would hand the same port to two apps, and the loser would crash-loop on EADDRINUSE — the exact failure the survival check exists to catch. The fixed-port case matters even though every in-tree app is"auto":_find_free_portskips only ports already in_allocated_ports, so a fixed port recorded after binding is invisible to a concurrent auto allocation. Conversely, because a port is now reserved before it is bound, a FAILED spawn must release it (_clear_failed_spawn_state) or that port is retired from the pool for the life of the process — one leaked port per retry of a broken app. The release is conditional on the app having no live record, so it can never revoke a running backend's reservation. - The early exit requires ownership, not just an open port:
_spawn_owns_listenerattributes the LISTEN socket to our pid or a descendant (the sandbox launcher execs the real server as a child). "Something is listening" is a different claim from "our child bound it" — with a fixed manifest port, another app or an unrelated process may already hold it, making our child the one about to die of EADDRINUSE; accepting that would report a doomed pid as started and route two apps at one backend. Mere elapsed liveness is likewise not accepted, so a child that crashes a few polls in is still caught. With no port to observe, or no port→PID tool on the host, the check polls the full budget exactly as before. - The ownership probe shells out to
lsof(~150ms), so it sits behind a cheap loopback-connect gate and the loop is wall-clock bounded: charging the probe to every poll interval made the failure path take ~2× the original budget, i.e. it regressed boot for exactly the apps slowest to start.
Per-app failure isolation is unchanged: one app raising or returning None never
affects the others or boot.
The query fails open for a rolling deployment whose older gateway does not yet
provide the endpoint, preserving dashboard access during frontend/backend
version skew. Once first-run setup has completed, later sign-out or CLI damage
leaves the dashboard mounted, fully usable, and shows no chrome at all — not
the full-screen gate and not a banner; the turn's own kiro-cli login error card
is the only signal. Non-owner browsers poll the
redacted readiness endpoint every three seconds while waiting for the owner, so
they observe completion without a reload; a manual Check again covers
external changes immediately.
React 18 + TypeScript + Vite 8 + Redux Toolkit + React Router v7 + Tailwind CSS 4 + DOMPurify. Source in frontend/, builds to src/kiro_crew/static/dist/.
Styling — Tailwind CSS 4, configured in CSS: website/src/tailwind-theme.css bridges every utility to a runtime token (--color-accent: var(--accent), …) and declares the variants and safe-area utilities; website/src/index.css imports it, lists the @source scan and defines the CSS custom properties (design tokens) for dark/light themes. @custom-variant dark ([data-theme="dark"] …) drives the Tailwind dark: variant off the data-theme attribute. Tailwind utility classes used throughout components (no separate CSS files per component). Compiled by @tailwindcss/vite; no PostCSS. Theme toggle smoothly crossfades via transition: background-color .25s, color .25s on body.
Design tokens — All colors use CSS custom properties mapped in website/src/tailwind-theme.css:
- Core:
--bg,--card,--text,--muted,--border,--accent(amber/orange) - Semantic:
--ok(green),--warn(amber),--danger(red),--info(blue) - AIM:
--aim/--aim-subtle(purple) — used for AIM agent badges, MCP server pills - Clarify:
--clarify/--clarify-subtle(amber) — used for task refine Q&A box - Diff:
--diff-add/del/hunk/meta— theme-aware diff colors for MarkdownRenderer
CSS utilities — Defined in index.css, used across components:
.topbar-glass— frosted glass effect (backdrop-filter: blur(12px) saturate(1.4)) on topbar.scroll-shadow— gradient mask fade at top/bottom of scrollable panels.table-striped— alternating row backgrounds vianth-child(even).skeleton— shimmer animation placeholder for loading states.focus-ring— unified focus outline (border-color + box-shadow + glow) for all inputs/textareas.card-glow,.stat-accent,.btn-sweep,.streaming-cursor,.think-bar,.typing-dots
Shared UI components (website/src/components/):
ui.tsx—Card,CardTitle,Btn,SendBtn,IconButton,Input,SearchInput,Badge,SourceBadge,StatCard(with skeleton loading),Skeleton,EmptyState,FilteredEmpty,PageHeader,PanelSectionHeaderAgentSelector.tsx— reusable agent dropdown with portal positioning, ARIA roles (listbox/option/aria-selected),SourceBadgesource pills, outside-click-to-closelayout.ts— theLAYOUTconstants (nav widths, chat sidebar width, max message width, log line cap, topbar height). Values live there only: the file's own header explains why they must never be interpolated into a Tailwind class string.InfoTip.tsx— portal-rendered?tooltipMarkdownRenderer.tsx— block-assembled rendering:useBlockAssemblerhook splits raw text into structuredContentBlock[](markdown/code/diff/mermaid/excalidraw) via state machine, then renders each block with specialized renderer. During streaming, unclosed code fences render as provisional blocks; on completion, full reparse from rawText produces clean final output. react-markdown + remark-gfm + rehype-raw for markdown blocks,HighlightedCodefor syntax-highlighted code blocks,DiffBlockfor diffs,MermaidBlockandExcalidrawBlockfor diagrams.fixCodeFences()repairs malformed LLM output.fixCjkAutolinkBoundaries()closes a barehttp(s)://run that GFM's autolink-literal extension over-ran, which it does whenever CJK prose puts punctuation straight after a URL with no space; the cut requires positive evidence, because CJK punctuation also reaches real URLs raw. Three rules supply it: a CJK bracket closing an opener from before the URL, a separator followed by a backtick, or a strong-emphasis delimiter that the prose prefix left OPEN (classified by the flanking rules the loadedremark-cjk-friendlyamendment actually applies, not by counting) and that is itself followed by CJK punctuation. The third rule's evidence is deliberately the fullwidth character class rather than "any non-ASCII": an ideograph is legal mid-path, so?q=a**中文must not read as a boundary. Consequently an all-ASCII paragraph is never cut even when it shows the same defect, and a bare CJK sentence continuing off a URL is not covered either — it is character-for-character identical to a legitimate CJK-titled URL. Skipped insourcePosmode, where inserting<…>would shift the inline-comment anchors.ExcalidrawBlock.tsx— read-only Excalidraw scene renderer for ```excalidraw fences, drawing to inline SVG (not an iframe, so the diagram reflows with the chat column and stays selectable). Delegates tolib/excalidrawScene.ts, which draws via rough.js — the same engine Excalidraw itself uses — and preserves each element'sseedso a diagram is stable across re-renders instead of jittering on every paint. The renderer is pulled in with a dynamicimport()so rough.js stays out of the entry chunk, matching theMermaidBlockprecedent. Covers rectangle (incl. roundness)/diamond/ellipse/line/arrow/freedraw/text/image/frame;isDeletedand unknown element types are skipped so a scene from a newer Excalidraw degrades rather than fails. Read-only by design: no editor, and opening a scene never mutates it.- Schema baseline — matched against the Excalidraw scene format
version: 2and the element set as of Excalidraw 0.18.x. Fidelity is faithful, not pixel-exact: bound-text layout inside containers and arrow re-routing are approximated, andembeddable/iframeelements plus shape libraries are out of scope. Record the baseline here when the matched version moves, so a "diagram looks wrong" report has a diff to work from. - Colours are canvas-relative, not theme-relative — the scene is painted onto its own canvas (its
viewBackgroundColor, else white) and rendered as a self-contained picture, the way an exported PNG would be. This is deliberate: scenes carry the author's explicit near-blackstrokeColor, so compositing onto the dashboard put a near-invisible diagram on the dark theme, while recolouring per theme would distort the author's palette (and inverting would invert embedded images too). An explicittransparentcanvas is treated as unspecified, because a transparent canvas cannot guarantee legibility against an arbitrary surface. Consequently there is nodarkrender option and no theme state for the render effect to go stale against. - Accessibility —
role="img"hides descendants from assistive tech, which would drop the diagram's own<text>labels (the only readable meaning a sketch carries). The graphic is therefore named with its joined text viaaria-label; when a scene has no text the role is omitted entirely rather than shipping an unnamed image.data-excalidraw-sceneis the stable hook for identifying a rendered diagram, sinceroleis conditional. - Failure — malformed JSON, a truncated stream, or an empty scene renders a one-line explanation (
components.excalidrawBlock.render_failed) above the source, muted and height-capped. Not danger-red and not full-bleed: scene JSON is hundreds of lines of machine data, so an unannotated red wall reads as a crash rather than a fallback. A diagram never costs the user the content. - Security — scene JSON is untrusted (model-generated or arbitrary file content). Embedded
files[].dataURLis restricted to a rasterdata:image/*;base64allowlist (svg+xmldeliberately excluded); colors are pattern-validated before reaching attributes; text is assigned viatextContent. The SVG is built withcreateElementNS/setAttributethroughout, never string concatenation.
- Schema baseline — matched against the Excalidraw scene format
DiffBlock.tsx— dedicated diff renderer with a single colored line-number gutter (adds/context show the new number, deletions the old; the number carries the add/del color and changed rows get a 2px inset edge bar — no+/-sign column). Raw@@hunk headers are not rendered: between hunks a slim "N unchanged lines" separator appears instead — a pill-shaped count bubble flanked by zigzag rules (the zigzag is the.zigzag-ruleCSS mask overcurrentColor, no SVG element in TSX). Per-file parse state resets at---/+++/diff --githeaders so multi-file patches never fabricate a cross-file separator, and the first hunk renders nothing. Unified and split views with forced line wrap (whitespace-pre-wrap break-words— these surfaces are width-constrained, so no horizontal scroll; the Monaco editor diff is the full-width surface), file meta headers, "Copy patch" button (raw patch, signs intact), provisional "generating diff…" indicator for incomplete streaming blocks. Supports both standard unified diff and kiro-cli+N:/-N:format. The Changes panel'sPullRequestPanel.tsxDiffViewrenders through the samePierrePatchcomponent, so the gutter looks alike, but the two normalize their patch headers separately —DiffBlockthroughbasenamePatchHeadersinutils/diffUtils.ts,DiffViewthroughwithUnifiedPatchHeadersincomponents/unifiedPatchHeaders.ts.TypewriterText.tsx— animated title reveal
Syntax highlighting — highlight.js (tree-shaken: js/ts/py/bash/json/yaml/html/css/sql/rust/java/md). Custom One Dark / One Light theme in index.css using design tokens. hljs.highlight() for known languages, hljs.highlightAuto() for unknown. Output sanitized via DOMPurify.
Pierre worker cache identity: code and diff surfaces load the portable worker through Vite's ?worker&url import in website/src/pierre/PierreImpl.tsx, then add the stable csp=wasm-v1 query parameter before construction. An immutable HTTP cache retains response headers as well as bytes; a worker cached before the gateway allowed wasm-unsafe-eval still enforces that old CSP even after the page and gateway upgrade. The library bundle's bytes and hash need not change when the selected engine or server policy changes. The versioned request obtains the current headers without clearing cookies, drafts, or other cached assets, and every retry shares the same cache key. The gateway policy and bounded WASM engine are unchanged; JavaScript unsafe-eval remains disallowed.
Pierre worker terminal diagnostics: each failed generation records a classification (error, messageerror, postMessage throw, or init timeout), a bounded one-line message, its generation, and its attempt in WorkerPoolSnapshot.failure. The final failure remains on the terminal unavailable snapshot. The passive ErrorNotice renders that detail, so its Ask the agent handoff carries the same facts. Terminal exhaustion emits one structured console.error; the desktop main-window frame-load-log bridge copies renderer errors into gateway-launch.log, which puts the cause in the desktop diagnostics bundle without adding a browser-to-gateway endpoint. Browser-only sessions retain the same record in their console.
Streaming rendering — Two-layer architecture: (1) useBlockAssembler hook parses raw text into ContentBlock[] via state machine tracking paragraph/fenced_code states; (2) BlockRenderer dispatches each block to specialized renderers (MarkdownBlock, CodeBlock, DiffBlock, MermaidBlock, ExcalidrawBlock). During streaming (streaming=true), unclosed code fences produce blocks with complete: false that render as provisional views. Diagram blocks are held at the placeholder until the fence closes — a half-streamed Mermaid graph or Excalidraw scene is not yet valid, so drawing it would only flash the fallback. On completion (streaming=false), full reparse from content produces clean final blocks. ChatMessage.rawText preserves the original unprocessed text as source of truth for reparse.
Security — All dangerouslySetInnerHTML content sanitized via DOMPurify (website/src/api/helpers.ts):
md()— renders markdown-like formatting (code blocks, bold, italic) + DOMPurify sanitizesanitize()— DOMPurify wrapper for pre-escaped HTMLesc()— plain text HTML escaping- CLI:
/etc/hostsupdate usessudo tee -a(notsh -c echo) to prevent shell injection
State management — Redux store with three slices:
dashboardSlice: SSE/WS connection state, chat slots array, approval mode (synced from backendyolofield in status — no localStorage), optimistic slot add/remove reducers (addSlotOptimistic/removeSlotOptimistic), async thunks for slot fetch / approval mode change. A slot created throughchatSlice.createSlotis registered by a matcher on that thunk's ownfulfilledaction (idempotent by key, since the liveslotsframe usually announces it first), so the sidebar row and the activated empty transcript commit together rather than as a separate optimistic dispatch followed by the activation. Close tombstone (closingSlots):chat/deleteSlot/pendingarms a per-key hold before the thunk'sremoveSlotOptimisticruns, and both authoritative list writers (sseSlots,fetchSlots.fulfilled, viaapplySlots) filter a held key out of the applied list — the server keeps a closing slot in its registry across the nudge-retirement / app-hook / history-save awaits before popping it, and coalesced pushes re-serialize at delivery, so a frame that still lists the slot is ordinary and used to put the row back until the post-pop frame removed it again (#11224). A list that OMITS the key does not release the hold: the two writers are unordered with each other (afetchSlotsreply can be older than live frames that arrived while it travelled), so a post-pop frame releasing the hold would let a pre-pop reply re-add the row. The tombstone is retired by the close's own lifecycle instead — released by a same-keyaddSlotOptimistic/createSlot.fulfilled, and on failure: the thunk's catch dispatchesreleaseCloseHoldBEFORE its recoveryfetchSlots(therejectedaction also releases, but it trails the thunk's unboundedawait navigation, so the refetch reply could otherwise land while the hold still filtered the very row it exists to restore). The hold is bounded twice over:deleteChatSlotcarries no deadline, so the thunk dispatchesconfirmCloseHoldthe moment the DELETE resolves — not onfulfilled, which trails its unboundedawait navigation— so a successful close whose peer transcript load outlasts the in-flight cap is not expired as if it had stalled; while the request is in flight the hold expires on the wall clock (CLOSE_IN_FLIGHT_MAX_MS, 30 s — a stalled server-side close degrades to the pre-fix "row comes back", not to a session hidden until reload); afterfulfilledit outlivesCLOSE_CONFIRMED_GRACE_FRAMESfurther authoritative lists (listing the key or not) AND everyfetchSlotsrequest that was already in flight at the 200 (slotFetchesInFlightsnapshotted onto the hold'sawaitingFetches; a pre-pop HTTP reply can trail the pop by a whole round trip, which no frame budget bounds) — the confirmed phase is itself wall-clock capped (CLOSE_CONFIRMED_MAX_MS, 30 s) becausechatSlots()carries no deadline and a stalled fetch must not pin the tombstone until reload; the fetches still awaited at expiry are recorded instaleSlotFetches(request → closed keys) and on arrival, once the stream is live, only those keys are distrusted in the reply (the on-screen row is kept for a key recreated meanwhile, otherwise the key is dropped) while the rest of it still applies — then yields to membership, so a server that keeps listing a slot it confirmed closed cannot hide a live session indefinitely. Each hold records itsdeleteSlotrequestId, andfulfilled/rejected/releaseCloseHoldact only on the attempt that owns the hold, so a stalerejectedtrailing a slow peer navigation cannot release a same-key retry's hold. The other tworemoveSlotOptimisticdispatch sites (switchSlot's 404 eviction, the artifact companion-chat close) remove only after the server has already answered and carry no tombstone; they are exposed to the post-confirmation straggler window alone (#11255).reconcileSlotsstill sees the frame's full membership, so a held slot's sub-agent state is not evicted before the close is confirmed (a failed close leaves the slot live). Per-slot write recency (slotWriteSeq/slotWrittenAt/slotFetchWriteMark):applySlotsis a whole-list positional replace — a row survives only while it isjsonEqualto the incoming one — so a single-slot write that landed after the server serialized its/api/chat/slotsreply and beforefetchSlots.fulfilledran was overwritten by the older server row (#11149). Every single-slot writer therefore goes through ONE chokepoint,patchSlotRow(and its URL-keyed siblingpatchSlotRowsWhereforpatchSlotSourceLinks), which stamps the row with a monotonicslotWriteSeq;fetchSlots.pendingsnapshots that counter per requestId, and onfulfilledany key stamped ABOVE its request's mark keeps the on-screen row instead of the reply's — the same substitution thestaleSlotFetchesclose pairing performs, one level finer, and the same rulenotificationsSliceapplies to an ack flag withackSeq. Deliberately NOT gated onslotsLoaded, unlike that close filter:sseConnectedclears the flag on every reconnect while LEAVING the rows in place, and reconnect is the one caller that refetches while the WS replay backlog writes into those same rows, so gating would disable the guard exactly where the race is likeliest. A cold boot needs no gate —patchSlotRowcan only stamp a row it found, and an empty list has none.slotWrittenAtis indexed through ak:prefix (stampKey) rather than by the bare key, so no key can reachObject.prototypeand the record needs noisUnsafeKeybail-out: a slot keyed__proto__is protected like any other, which is what makes "every write is stamped" true without exception. The stamp is the cost of reaching a mutable row at all, not something a writer opts into, which is what makes a writer added later protected by construction;dashboardSlice.rowWriterChokepoint.test.tsfails the build on a reducer that takes a row out ofstate.slotsitself without marking the lookup read-only, policing lookup, iteration AND index access because the pre-fixpatchSlotSourceLinksreached its rows by iterating. Deliberately scoped to the HTTP path: aslotsframe and thesse*deltas arrive on one socket and so in order, and the single writer whose DISPATCH is deliberately deferred (touchSlotActivity, coalesced once per frame) already refuses to move either watermark backwards for exactly that reason, so ordering is not whatsseSlotsrelies on. An HTTP reply carries no such ordering. Two costs are accepted: a write landing between the dispatch and the server's serialization is counted as outranking too (the client cannot separate those instants without a server-minted stamp on the reply), and a withheld row's server-only fields wait for the next authoritative frame — bounded and self-healing, where a clobbered local write has no re-delivery path at all. A write that changed nothing (atouchSlotActivityboth watermarks rejected, asource_statusdelta matching no link on that row) is not stamped, so a no-op cannot withhold a reply. Stamps are pruned with the rest of the per-slot state when an authoritative list the caller vouches for drops the key. YOLO state is backend-authoritative:sseStatusreducer syncsapprovalModefromstatus.yolo; page load fetches/api/statusimmediately for instant sync.chatSlice: active slot, messages, session history (paginated), WS chunk/done handling (accumulate chunks into streaming, finalize on done), optimistic slot mutations, WS-ahead guard onswitchSlot.fulfilled, async thunks for all slot/history CRUD. Idempotent append: achat_messageframe is dropped when the target list already holds a row with the same server row id (meta.mid, stamped by_ChatSlot.append) —isRedeliveredMessage, called from ONE chokepoint per path (sseChatMessageactive slot,applyNonActiveFramebackground pane) placed so it dominates every branch that creates OR MUTATES a row: thetoolinsert (which splices before a trailingstreamingrow), theassistantreconcile (which overwrites that row, so a late redelivery of an old frame would clobber a NEW segment's live content), theuserecho reconcile, and the generic push. Theassistantreconcile merges the frame'smetaonto the finalized row, which is how a client-minted streaming row acquires its server id. A frame with NOmidis never deduped — client-minted rows have no server identity yet, and channel-replayed rows carry nometaat all — because declining renders a duplicate at worst while guessing would drop a real message. Identity is deliberately NOT (ts, role, content): a coarse OS clock stamps two same-tick appends identically (the collisionmergePreservedClientTspass 1 already guards) and two byte-identical messages are legitimate, so that tuple either misses a redelivery or silently discards a distinct row.switchSlot.fulfilled's re-attach of a local trailing reply tests the id EXCLUSIVELY when the local reply has one — a content fallback running alongside it would let a stale snapshot row with identical text (a different row, different id) match and drop the newest reply. Content equality applies only to a reply with no id yet (one_donefinalized without anassistantframe), which the server history cannot hold under a different id anyway. Preferring the id also survives the redaction asymmetry — that endpoint redacts on emit while the streamed copy is raw. Invariant: one persisted transcript row renders exactly once, no matter how many times its frame is delivered (the post-restart-reconnect duplicate-answer bug). Each drop increments the diagnostic counter_redeliveredFramesDropped(nothing renders it): the dedup makes at-least-once delivery invisible, and the duplicate bubbles were the only user-facing signal that something upstream re-emits frames after a restart, so a non-zero count is that signal — readable in a Redux state dump, 0 in steady state. Replayed-chunk guard: WS delivery is at-least-once, so the reducer is the single owner of a per-slot replay floor (lastChunkSeq,slotRun[key].lastChunkSeqfor background panes; reset by_done). TheuseWebSocketflush buffer only batches: it hands the reducer each chunk as a seq-tagged part, and both chunk paths append only the parts above the floor and derive the "N chunk(s) missed" markers from the kept seqs. Chunk seqs are a per-slot counter that continues across turns (_ChatSlot._chunk_seq, shared with the remote relay), so a later turn always numbers above an earlier floor. The floor is seeded from the slot snapshot: the runner stamps every windowchunkrow with its wireseqand processgen,_collapse_wire_rowscarries the run's newest seq onto the folded row, and_prepare_messagesemits both on thestreamingrow, whichswitchSlot/refreshSlot/warmSlotCache.fulfilledraise (never lower) the floor to when the slot is running and clear when it is idle. Agenthat differs from the floor's replaces it instead of being compared (a restarted gateway numbers from 0 again).switchSlot.pendingparks the outgoing slot's floor on its run entry and takes the target's;rejectedrestores the origin's. A chunk that raced the reconnect refresh therefore lands once, not twice (the duplicated leading fragment). A snapshot row withoutseq(an older gateway) leaves the floor as it wasnotificationsSlice: notification list with add/delete/clear/ack/unack, async thunks for fetch/delete/clear.addNotificationdeduplicates byts.ackNotification/unackNotificationare optimistic (.pendingcase) and awaited, so.fulfilledmeans the backend holds the write. WS eventsnotification_ack/notification_unacksync ack state across tabs;ackNotificationByTs("*")handles bulk ack-all;notifications_clear(broadcast by the backend after a clear-all) dispatchesclearAllNotificationsso every view — not just the one that cleared — empties its list and the bell badge converges to 0. On WS reconnect,fetchNotifications()re-fetches to recover missed notifications during disconnect.fetchNotifications.fulfilledmerges rather than replaces: membership, ordering and every non-ack field come from the response, but an item whose ack flag changed locally after the request began keeps the local flag. A monotonicackSeqcounts local ack-state changes (optimistic flip, its confirmation, and every ack/unack frame — including the view's echo of its own ack, since the backcast has no originator exclusion),ackSeqByTsrecords theackSeqper item, and the thunk snapshotsackSeqat request start alongsideclearSeq. Without this, a response rendered before an ack reverts it and the row reappears as unread. Stamps are dropped whenever their item leavesitems(delete, WS remove, clear, ring-cap eviction, or the merge itself), so the map is bounded byNOTIFICATIONS_RING_CAP. One rule governs every write to an item's ack flag, whatever produced it: a response may apply only to an item whose stamp has not advanced since that request began. The fetch merge is one instance (snapshot = whole-stateackSeq); the ack/unack/ack-all confirmations are the other (snapshot = the item's own stamp, read afterpendingwrote it), which is what stops tab A's slow ack confirmation from overwriting a newer unack that already arrived from tab B, and stops an ack-all from marking a notification that arrived while it was in flight
Real-time updates — Single WebSocket at /api/ws (useWebSocket hook) multiplexes all events: dashboard, slots, slot_title, notification, notification_ack, notification_unack, notifications_clear, refresh, chat_message, chat_chunk, chat_done, log, refine, sessions_restarting, heartbeat, tool_call, context_usage. Exponential backoff reconnect (1s→2s→4s→max 10s); on reconnect re-fetches slots via Redux dispatch — no page reload unless the server version field in the dashboard status message changes (actual code update). This preserves unsent messages, scroll position, and form state across transient disconnects. WsContext provides log subscribe/unsubscribe to LogsPage. SSE survives only for logs (useLogSSE at /api/logs); the /api/stream route is still served but has no frontend consumer. Chat send uses AbortController with 10-second timeout — if the backend is busy starting kiro sessions, the fetch times out gracefully without showing an error (the message was received server-side; chunks arrive via WS when the session is ready).
Routing — App.tsx uses React Router <Routes> with paths: /chat, /notifications, /overview, /worlds, /system, /capabilities, /projects, /logs, /hooks. /agents and /mc-agents redirect to /capabilities (Agents is the panel's first tab). Default redirects to /chat. SPA fallback middleware in server.py catches 404s on non-API GET requests and serves index.html.
Request a Feature — The header action creates a chat slot and queues the self-contained feature-request seed through the slot's pending-context endpoint. It then sends and persists only the localized, user-facing request text. Pending context is consumed by that turn without becoming a transcript message, so internal workflow instructions never appear as user-authored history. Every drained frame also carries a silent-consumption contract line (_CONTEXT_FRAME_CONTRACT in chat_runner.py, between the [Background context from "<source>"] delimiter and the content): follow the block, never quote/echo/reveal it, reply only to the user's visible message. Without it the agent recited the injected workflow verbatim as its visible reply on every click (#4780) — the contract is part of the frame so every producer (app-kit inject, artifact companion, Slack thread backfill, feature-request) is covered without restating it per payload. The contract is best-effort model compliance, not a confidentiality boundary — a model can still ignore it, so pending-context payloads must never carry secrets or content that would be harmful if echoed. If context injection fails, the visible request still sends and the chat remains usable.
Nav sidebar — Collapsible: full mode (236px with labels) or icon-only mode (74px). The sidebar toggle lives in the rail's own top "menu row" (hamburger, plus a panel-left-close collapse control on the right while expanded) — not in the topbar, which shows only the brand. State persisted in localStorage('mc-nav'). Three vertically-stacked regions: (1) top-fixed — menu row, then Sessions (the chat surface, formerly labeled "Chat"), Schedule, Artifacts, Knowledge, then an Apps section header whose right side is an accent-colored Explore link (lucide layout-grid) to the App Store at /apps (the store's surface is hiddenFromNav; the link carries the data-onboarding-nav="apps" anchor); (2) Apps frame — the enabled-apps list scrolls in its own frame (flex-1 min-h-0 overflow-y-auto) so many apps never push the pinned sections; drag-reorderable via dnd-kit sortable (SortableAppNavRow + DndContext/SortableContext/DragOverlay, MouseSensor 8px distance + TouchSensor press-and-hold so a plain click still navigates): rows reflow to open a gap as one is dragged, the source dims, and a DragOverlay ghost follows the cursor; order persists to localStorage('mc-app-nav-order') (arrayMove); reorder is scoped to the currently visible Apps rows (the "N more" overflow collapse hides the rest); (3) bottom-fixed — Agent Capabilities, Developer (dev mode only), Settings, and a Contact Us row with icon links to kiro.dev, the GitHub repo, and the Discord community (folds away — max-h-0 + inert — while collapsed). Collapsed mode renders icon-only rows with portaled hover labels; the Explore row fades in and slides up into place. The former Shortcuts nav row moved to Settings → Shortcuts (ShortcutsPanel, sharing the same content the Alt+K shortcuts modal shows; tab sits above Developer).
Preview-gated surfaces — a registry entry may carry previewFlag, the localStorage key of a per-device opt-in (utils/previewFlags.ts, all keys prefixed mc-preview-). While the flag is off the surface is not advertised anywhere. The safe list has a name: consumers read getAdvertisedSurfaces() (= getBuiltinSurfaces() minus gated entries) rather than filtering per call site, because a call site that reaches for the unfiltered list and forgets the filter leaks an unreleased surface silently. App.tsx derives advertisedNavItems once and feeds BOTH rail list paths (Main and the Apps group) from it; the Search Everywhere Pages provider reads the same accessor. The bottom-fixed rows are looked up by id (settings, capabilities) and are core surfaces that are never gated. Gated surfaces deliberately REMAIN in getBuiltinSurfaces() so registry-wide invariants (every surface carries a translatable labelKey) still cover them. Their route stays registered, which is what makes the surface reachable once the flag is on. The flags are listed as one card per feature on Settings → Developer → Feature Previews (the section is pages/settings/FeaturePreviewsSection.tsx, mounted by DeveloperPanel on the always-visible Settings tab — NOT behind Developer Mode: it is a consent gate like Developer Mode itself, not an internals view, so it sits beside that gate rather than behind it. It keeps the registry's "surface" vocabulary in code while its copy says "features" and "pages". It used to be a ?tab=feature-previews tab on the standalone Developer page; DeveloperPage replace-redirects that legacy link to /settings/developer?highlight=key:feature-previews-section, whose key: form rings the element carrying that data-setting-key — the section's wrapper, so the whole moved section is ringed rather than one card; the Developer page's rail footer carries a signpost link to the same target for users who navigate there by memory rather than by the old URL. Because the file lives under pages/settings/, its three toggles ARE indexed into Settings search via PANEL_TAB_MAP — the search hit reaches the labelled opt-in switch, not the page it holds, which stays un-advertised until the flag is on), which fires mc-preview-flag-changed; usePreviewFlagRevision() turns that into a revision number the rail uses both to re-render and as a memo dep, so the row appears without a reload. A cross-tab storage event on any mc-preview- key does the same. Two occupants today: /webhooks (the endpoint is supported, the page is not finished) and Crew Members — PREVIEW_CREW gates the /members rail item from one card. (It used to gate a second door too, the sidebar create-menu's "New Crew Mode chat" entry; Crew Mode retired in favour of the Members page, and that entry is now a "Crew Members" door rendered whatever the flag says — ChatSidebar reads the flag through usePreviewFlag only to decide whether the click lands on /members or on the Settings card that turns it on, via settingsPath({ tab: 'developer', highlight: SETTINGS_CREW_MEMBERS_PREVIEW_ID }).) Crew Members is the case a previewFlag alone does not cover: the browser-tab attention count reads the registry directly rather than getAdvertisedSurfaces(), so selectAllSurfacesAttention applies the predicate itself — the tab title is an advertisement too, and a gated surface that still contributed would show a (1) with no rail row to trace it to. hiddenFromNav is the deliberate opposite there: it keeps contributing, because it IS advertised, just on the topbar bell rather than the rail. The gate is on the INGRESS only: /members stays routable, so turning the flag off never orphans existing work. Unlike Webhooks, the Crew Members surface is NOT also hiddenFromNav, because the rail is where it belongs once released — so dropping its previewFlag (plus the ChatSidebar read and the card) IS the release. Retiring a gate is deleting the previewFlag and the feature's card. This is distinct from hiddenFromNav, which is permanent and means "rendered elsewhere" (the App Store's Explore link, the topbar notifications bell).
Agent monitor — Ctrl+G (useKeyboardShortcuts.ts, predicate isAgentMonitorChord): opens the Subagents activity tab (openActivityToTab('subagents')) and routes to /chat, since the activity panel is owned by the chat page. This is the one chord that is literal Ctrl on every platform rather than ⌘-on-Mac: the kiro-cli backend emits Press ctrl+g to monitor progress. into its crew-pipeline tool result, that string lives inside the backend binary and cannot be re-worded per OS, so the chord the user is told to press must be the chord that fires (on macOS find-next is ⌘G, leaving ⌃G free). It requires exactly one primary modifier and no Alt/Shift, and deliberately fires inside text fields — the hint is read while a crew is running and focus is normally in the composer, so an input bail-out would make it dead exactly when it is needed. It is skipped for .xterm targets, where Ctrl+G is BEL and belongs to the PTY. Because the branch requires ctrlKey && !altKey, KeyG is deliberately not added to RESERVED_PANEL_CODES: it cannot shadow a downstream Alt+G panel registration, so reserving it would over-claim the panel-navigation extension seam.
Panel toggles — user-rebindable, one id per panel (lib/panelToggleShortcuts.ts): four ids — left-sidebar, session-panel, side-panel, terminal — each carrying a Chord whose mod resolves to Cmd on macOS and Ctrl elsewhere. Defaults: session list mod+B, activity/side panel mod+\, while the nav rail and the docked terminal both ship unbound (null) for the user to opt into. localStorage('mc-panel-toggle-shortcuts') holds overrides only, so a code default reaches every user who never touched it, while an explicit null is a deliberate "cleared to unbound" the loader must preserve rather than collapse back to the default; a write broadcasts mc-panel-toggle-shortcuts-changed so the live keydown handler, the Alt+K reference and the Settings → Shortcuts rows re-read without a reload. An unbound id renders its row with the unset ("Not set") state rather than being hidden, so it stays discoverable. The terminal's row is hidden when dashboard.terminal.enabled is false, matching the nav rail. Why the terminal alone has no proposed default: its chord is on the skip-shell allowlist below, so by construction it is taken from the PTY — meaning any default spends one of the user's shell keystrokes for them. mod+J, the natural pick (VS Code's Toggle Panel, which HOSTS its integrated terminal), is ^J on Windows/Linux, i.e. readline's accept-line, so a user pressing it instead of Enter would close the panel mid-command; VS Code's terminal chord proper is literal Ctrl+`` on every platform, unrepresentable in this Chord model (mod is Cmd on macOS by definition, and ⌘ is the macOS window cycler). Widening the chord model for one binding was rejected, and so was choosing a shell keystroke on every user's behalf — the binding is left to whoever wants it. Skip-the-shell allowlist: these chords otherwise yield to any .xterm target, because a keystroke aimed at a shell belongs to the shell — but a terminal toggle that yields would open the panel, focus its own shell, and then be unable to close it. PANEL_TOGGLES_SKIPPING_SHELL names the ids exempt from that yield (currently just terminal), mirroring VS Code's terminal.integrated.commandsToSkipShell as a per-COMMAND allowlist rather than a per-key rule, so a future panel with the same need joins a set instead of adding a second special case. The allowlist is honoured by a capture-phase keydown listener scoped to terminal targets, not by the bubble-phase dispatch: a control-character chord such as Ctrl+J is consumed by xterm, so a bubble listener never runs — the same structural point VS Code makes by consulting its own list inside the terminal's key handler. That listener calls preventDefault + stopPropagation, so the PTY never receives the keystroke; everywhere outside a terminal the bubble path still owns these chords, leaving defaultPrevented deference and handler ordering untouched. A panel whose action is absent — terminal disabled, or a popout/embed with no such panel — is treated as unbound on both seams, so the chord falls through untouched instead of being swallowed.
Session titles — auto-generated after a few turns via background LLM call in chat_title.py (_maybe_auto_title), pushed to all clients via slot_title WS/SSE event, persisted in chat history JSONL metadata via ConversationLog.update_metadata() together with title_origin (auto = background titler, user = manual rename; a legacy title with no stored origin rehydrates as user). Title input scans reserve a bounded allowance for the dashboard's 20-file upload limit, remove generated image/file references, and then cap retained user text before prompting or fallback selection. Non-image metadata is validated, length-limited, and stored in token-index order so each generated reference resolves directly without scanning every path. Manual trigger via POST /api/chat/slots/{slot}/generate-title — it prompts from the last _TITLE_PROMPT_WINDOW conversational (user/assistant) messages (the user regenerates because the name no longer fits, so the prompt must reflect the current topic, not the opening turns that initial titling reads; filtering before slicing keeps a tool-heavy tail from starving the window); generation errors use the same sanitized first-user-message fallback, while attachment-only placeholder results remain untitled so a later automatic attempt can retry. Cancellation releases the in-flight guard without starting a pending retry. Max 5 auto-title attempts before falling back to the first usable user message. Background title refresh (chat_title.py:maybe_refresh_title, fired from chat_done for already-titled slots): an auto-origin title is re-examined at the user-turn milestones in _TITLE_REFRESH_MILESTONES (8 and 24) through the same _bg one-liner path, with a KEEP reply leaving the name alone; each milestone fires at most once (attempt-counted — KEEP/error consumes it) and the consumed mark is persisted as title_refresh_mark so restarts cannot re-spend the budget. A manual rename records origin user, which locks the refresh out permanently, and bumps _title_epoch so an in-flight background attempt stands down instead of clobbering the rename (the reveal animation is cosmetic-only and never assigns slot.title). The generated name follows the workspace UI language (dashboard.language) rather than the conversation's: _ui_language() resolves the tag via context.ui_language_tag() and _build_title_prompt() interpolates a language directive outside the delimited transcript, with "" (auto) omitting it and leaving the prompt byte-identical — see config.md § Dashboard UI language for the prose-guard and reveal-animation consequences of naming in an unspaced script.
Chat cold-URL deep links (pages/chat/useChatPageSessionController.ts, every param captured once at mount) — ?sid=<key> activates an existing session, ?new=1 creates a fresh one, and ?new=1&prefill=<text> creates one and seeds its composer with text. The prompt is staged through the existing utils/navIntent::writePrefill (sessionStorage kirocrew_prefill, 30s TTL, consumed by ChatPage's slot-restore effect) inside newSlotMutation.onSuccess — before the replace navigate that drops the query string, since the reader on the other side has nothing to seed from afterwards — and the captured value is cleared on use, so retrying a failed create cannot re-seed a session the user has since typed into. It seeds only and never sends: auto-send stays behind the signed ?token= channel flow, so a link an external launcher (Slack card, bookmarklet, CLI --open) can build cannot spend a model turn. prefill is read only when new=1 is also present, because a bare ?prefill=<v> is an in-app SENTINEL here — ?prefill=1 from the file explorer's "Chat about this file" and ?prefill=plan from a project idea's "Edit in chat", both carrying the real text in Redux pendingInput — so honouring one would spawn an empty session and type the marker into it. A landed slot list that omits a requested sid starts the 5-second missing-session deadline but does not make that verdict permanent: a later authoritative frame carrying the key clears the banner and activates the session only if the active session has not changed since the deadline; after a change it clears the stale banner without changing the active session. The controller latches every post-deadline active-session change, so an away-and-back sequence still suppresses reactivation. newSession=1 is a different in-app marker, paired with autoSend=1 by ProjectsPage/DevFleetPage/PromptsTab, and deliberately does NOT alias new=1: tripping the mount create as well as send()'s forceNew path would make one click create two sessions.
UI Pages
- Chat (
/chat, default) — multi-session parallel chat, Slack-style grouped messages with timestamps (MMM DD, YYYY, HH:MM), KiroCrew logo as assistant avatar, full Markdown rendering viareact-markdown+remark-gfm+rehype-rawwith Mermaid and Excalidraw diagram support (mermaid andexcalidraw fences both render as inline SVG in the message flow;.excalidrawscene files opened in the file panel render as diagrams rather than raw JSON), syntax-highlighted code blocks (highlight.js), and clickable file paths (inline<code>containing paths → reveal in Finder via/api/reveal), session sidebar with titles and scroll-shadow panels (notifications moved to dedicated page), collapsible history section (default collapsed) with source tags (🖥 dashboard / 💬 slack) and creation dates, session delete from history,EmptyStatecomponent when no session is active. The tabbed SidePanel includes a singleton Changes view when a conversation contains supported PR/MR URLs; it renders provider metadata, lazily expanded diffs, review discussions, and CI checks, while sidebar source chips show provider/number plus cached state and CI. A chip's CI rollup is suppressed once its lifecycle state is terminal (mergedorclosed) — the lifecycle glyph is the only meaningful signal there, and a closed pull request's rollup can stay pending indefinitely (GitHub parks fork-PR checks inPENDING/ACTION_REQUIREDwhen the PR is closed before a maintainer approves the run), so a chip that still rendered CI would spin a "checks running" spinner forever on work nobody is waiting for. The sidebar chip (ChatSidebar.tsx::showsChipCi) and the panel's source-strip tab (PullRequestPanel.tsx::SourceTabState) apply the SAME rule; an absentstateis not terminal (the provider status has not been read yet), so such a chip still renders CI. For open non-draft sources the panel header surfaces a merge-blocker banner derived from the normalized merge-state fields (allow-list gated on the raw provider state beingopen(GitHub) oropened(GitLab), so merged/closed/locked/other states never banner):mergeable === 'conflicting'renders a danger "Merge conflicts" banner whose "Add to chat" handoff asks the agent to resolve conflicts — preferring a merge of base into head on shared branches and reserving rebase +--force-with-leasefor unshared ones;mergeStateStatus === 'need_rebase'(GitLab fast-forward-only projects) renders a warning "Rebase required" banner whose handoff explains that a merge commit cannot unblock the MR and asks the agent to coordinate before rewriting a shared branch, rebasing with--force-with-leaseotherwise;mergeStateStatus === 'behind'renders a warning "Branch is behind" banner whose handoff prescribes a no-history-rewrite update (merge base into head or the provider's update-branch affordance, never an unprompted force-push); andmergeStateStatus === 'blocked'renders a warning "Merge blocked" banner for branch-protection gates (human-actionable, no handoff). The panel's own payload is pinned (staleTime: Infinity) and never refetches on its own, so a merge state that settles after the payload loaded is carried by the server-driven chip↔payload invalidation protocol rather than by any client-side comparison: the merge pair is part of both the chip-status projection and thesource_statusdelta, so a chip refresh that observes a changed merge pair drops the full payload server-side and notifies owner dashboards, and every window refetches. Each merge field is recorded into the chip entry only once it is real — an unanswered field is omitted rather than written asunknown— and the two fields are recorded independently, because GitLab settlesneed_rebaseand its branch-protection gates in the detail field whilemergeablestaysunknown; dropping the detail because its sibling is unknown would leave exactly those banners invisible. Omission alone is not enough for "still computing" to be harmless, though: every writer replaces the chip entry WHOLESALE, so an omitted field would ERASE a settled one rather than read as "no news" — and because both providers evaluate lazily, an unsettled read of an already-known conflict is the common case, not a rare one. Both writers (chip refresh and full-payload write-through) therefore carry a settled merge field forward when the fresh read has no answer, the same keep-known rule already applied to theciglyph. A real answer always wins, including one that CHANGES the value, so the carry only fills a gap and cannot pin a stale verdict; it stops once the source leaves an open state, where the pair is meaningless and permanently unanswered. The full-payload projection (status_from_full_payload) must therefore project the merge pair too: a field the chip path records but the full-payload path omits would re-appear as a changed transition on every chip refresh and spin the invalidation loop, which the flap damper (_CHECK_FLAP_DAMP_THRESHOLD) bounds structurally. Without this the conflict banner appeared only once the user hit refresh. Frontend discovery ignores everychunk/streamingmessage regardless of its array position and publishes a URL only after that message becomes durable, so appended thinking/tool/stop events cannot settle a numeric URL that an earlier stream is still extending. Extraction, incremental index state, andPullRequestPanelrendering preserve first-seen order while enforcing the same 64-source cap. First-mention attribution decides Changes vs Resources: a PR/MR becomes a Changes source only when its FIRST durable mention was agent-authored (assistant / tool / thinking output — e.g. agh pr createresult). A PR whose first mention was a user message is treated as a referenced link and surfaces in the Files-tab Resources list instead — it never auto-opens the Changes tab or fires a new-source notification. Because the dedup map records the first mention, a later agent echo of a user-referenced PR cannot reclassify it as a Change (and a later user reference cannot demote an agent-surfaced one). The 64-source cap is applied per role, so a flood of user-referenced links cannot starve agent Changes sources; user-first links are retained (bounded) solely for echo suppression and are excluded from the emitted Changes sources. The emittedPullRequestLinkshape is unchanged, so backend source APIs and the Changes panel are unaffected. Files-tab inline file preview: opening a file from the SidePanel Files tab shows it inline (the file list is replaced by the file's content plus a "Back to files" bar) via the sharedMarkdownPaneleditor — the same view / Edit-Preview toggle / dirty tracking / Save / discard-guard used by document tabs — instead of spawning a separate document tab. The inline working copy lives in a module-level draft store (usePanelTabs, keyed by slot + path — the same file edited in two chat slots keeps independent drafts;usePanelTabsowns the key format) that sits ABOVE the SidePanel subtree, so an in-progress edit survives everything that unmounts the panel (close control, activity-tab switch, chat-slot switch, and the automatic force-collapse on window resize) and is restored on reopen; it is in-memory (parity with document-tab content, which is likewise stripped on persist and re-read on reload) and is cleared on save and on explicit discard. Exactly one editor per path is enforced at open time (there is no runtime "yield" of an already-open editor): opening a path from the Files list that is already open as afile:${path}document tab focuses that tab instead of opening a second inline editor, and a chat-link open of a path already open inline routes back to the inline editor (ChatPage.handleFileOpen) rather than spawning a document tab. A failed read (HTTP error or network-level rejection) renders a retryable error rather than an editable placeholder (so a save can't overwrite the file with empty/placeholder content), and a successful save refreshes the shared['file-read', path]cache so a reopen never seeds stale pre-save content. Per-slot seen-source state is also mirrored to a quota-safe, globally bounded localStorage record, so route remounts and page reloads do not reinterpret historical links as new sources or override a persisted panel dismissal. During uncached slot switches or reloads, source reconciliation preserves the persisted Changes tab whileslotLoadingis true and only closes it after hydrated history confirms that the slot has no supported source URLs. Chat uses WS for streaming (?ws=1mode): POST returns immediately, chunks arrive via WebSocket. Auto-approved tool calls broadcast via WS as ephemeral cards (not persisted to messages), inserted before streaming message in Virtuoso list. Agent selection: WelcomeView (pre-first-message) has agent picker that setspendingAgentstate — on first send, slot is created with that agent viaPOST /api/chat/slots {agent}. Agent selector dropdown also in top bar next to session title for mid-session switching. Agent badge (aim-colored pill) in sidebar slot list. MCP info button shows per-agent MCP servers: non-kirocrew agents show only their own MCPs from agent config; kirocrew shows all global MCPs. Tool/approval payload viewer (ToolDetails, website SPA): tool-call and pending-approval cards render the payload with a Raw / Formatted toggle (beside the Input/Output control, shown only for JSON-ish payloads where the two modes differ). Formatted renders the parsed JSON object as a key→value table — multi-line command values show real line breaks and quotes (JSON-decoded), with bash syntax highlighting on command-bearing keys (command/cmd/script/shell/bash) via the shared worker highlighter; Raw shows the exact verbatim payload with escaping intact (and is the fallback for truncated/streaming or non-object payloads). - Notifications (
/notifications) — dedicated page with left/right split layout. Left: category tabs (All/Cron/Hooks/Heartbeat/Agent/Approval/Subagent/Tasks), search filter, date-grouped list (Today/Yesterday/This Week/Older). Right: detail panel with source label, full timestamp, Read/Unread badge, markdown-rendered body, and jump-to-source buttons. Jump logic:slotmeta → "💬 Go to Chat" (active tab) or "💬 Resume Chat" (from history);slack_linkmeta → "💬 Open in Slack" (deep link);task_id→ "💬 Continue in Chat";job_id→ "⏰ View Cron Jobs". Cron notifications haveCronAckBarfor acknowledge/delete. Notification meta includesslot(subagent/heartbeat from dashboard),slack_link(subagent from Slack),session_key(webhook),job_id(cron),task_id(task runner)._notif_meta()helper onGatewayOrchestratorbuilds meta fromparent_key. StatCard row: Total/Unread/Cron/Hooks/Heartbeat. Nav badge shows unread count. - Overview (
/overview) —StatCardcomponents (with skeleton loading) + tabbed management console:- Memory tab: editable preferences.md / projects.md with Save buttons, read-only daily history. A Memory store card heads the tab — a picker over
GET /api/memory/storesand the selected store's lineage, counts and newest backup — and the store it names is sent as?store=on the document reads and writes and on the carve, retired and backup cards below it. Because both the listing and the parameter are owner-gated, a non-owner session gets no picker and keeps reading the global store. Memory Graph Explorer: sigma.js (WebGL) visualization of memory relationships (nodes = memory entries color-coded by group; edges link entries to the projects they mention). Node positions come from a one-shot client-side d3-force layout (time-bounded, then stopped — no live physics solver); the server sends only nodes/edges. It takes no?store=, so it always describes the global store whichever store the picker names. - Cron tab: add job form (with shared
AgentSelectorcomponent) + striped job table with Pause/Resume/Delete actions - Lessons tab: add lesson form + lesson table with Delete actions
- Skills tab (CRUD): + New button with create form (name + SKILL.md editor), installed skill list with click-to-view, ✏ Edit button with inline textarea editor + Save, ✕ Delete with confirmation, name sanitized to lowercase + hyphens. AIM Skills section shows skills from
~/.aim/grouped by package with Uninstall button per package. Skills are fully AIM-managed — no bundled skills;AIPowerUserCapabilitiesinstalled by default via setup/update. - MCP Servers tab: Controls
~/.kiro/settings/mcp.json(global config that kiro-cli ACP loads at runtime). Server-level enable/disable setsdisabled: true/falsein global config and syncs@serverto kirocrew.jsontools/allowedTools. Per-tool enable/disable setsdisabledToolsarray in global config. Probe All discovers tools per server, preserves enabled/disabledTools state across probes. Enable All / Disable All bulk buttons. Tool chips: green = enabled (clickable to disable), strikethrough = disabled (clickable to enable). Apply & Restart at top bar resets all active sessions. Live server badges (🔌 color-coded by status). - Agent Config tab: JSON editor with Save + warning about
kirocrew setup --agent-only
- Memory tab: editable preferences.md / projects.md with Save buttons, read-only daily history. A Memory store card heads the tab — a picker over
- Settings → Voice (
/settings/voice) — the Speech-to-Text card (website/src/pages/settings/SttSettings.tsx, rendered insideVoicePanel.tsx, so voice-in and voice-out settings share one tab). Controls: enabled, provider (local/apple/transcribe), model, language, and the dictation knobsstreaming/silence_ms/partial_interval_ms/idle_evict_secs/endpointing/dictation_panel. Nothing in the offered set is hardcoded in the frontend:providers,models,language_codesandstreaming_providersall come fromGET /api/config/stt, because what a host can run differs per host (_stt_providers()omitsappleentirely where macOS is too old or the Swift toolchain is absent, rather than offering an option that cannot be selected) and it is the single source of truth for what GET advertises AND what PUT accepts.streaming_providersis served fromstt_stream._STREAMING_PROVIDERSso the streaming controls gate on a CAPABILITY rather than on a provider name; a hardcoded copy goes stale in the permissive direction, offering a provider this host cannot run or hiding a control for one it can. PUT applies floors only on the three millisecond/second knobs, each floor owned by the module that reads the value (vad.MIN_SILENCE_MS), because a value above the detector's own cap is inert rather than unsafe; aboolis rejected explicitly since it subclassesintand a checkbox value would otherwise persist as1.- Runtime state is a different endpoint from settings.
GET /api/stt/statusserves what a panel polls: the availability reason as acodeplus advisory prose (transcribe.availability_detail()), the resolved model withmodel_presentand its byte size,engine_loaded(whether a model is resident, which is the difference between a ~30 ms transcription and one that pays a load first), and the livedownloadstate.POST /api/stt/preparestarts or joins the one-time model download and answers 202 with the current transfer state; concurrent callers share one transfer because the model store serialises them behind its own lock, so the in-flight check is only there to stop a polling panel accumulating tasks.POST /api/stt/prewarmanswers 202 and loads plus warms the recogniser when the user REACHES for the mic rather than when they release it: a first-ever load compiles a GPU pipeline (measured at 7.4 s) and the first decode after any load allocates its graph (154-528 ms), so both are paid while the user is still speaking. - Those four are dashboard-only.
_deny_app_tokenrefuses an app token onstatus/prepare/prewarm/ffmpeg/download:request["user"]is truthy for an app token too, so a cookie check alone does not separate a browser from an app that named the path in its manifest, and these start a download and warm a resident model inside the gateway, which is operator setup rather than something an app earns by naming a path. It requiresrequest["app"] == "", so an ABSENT key is refused along with a non-empty one and an unauthenticated route can only fail closed. The transcription surfaces (GET /api/ws/stt,POST /api/stt/transcribe) are deliberately NOT gated this way, so a shipped app can still transcribe. - There is no dependency-install button. Desktop releases already carry the recognizer and pinned
imageio-ffmpegdecoder; the only user action is selecting an on-demand model and clicking Download now. A source install can need avoice-extra command in the gateway's OWN interpreter plus a system FFmpeg command for compressed input; project-venv binaries are not trusted executable storage. These commands are suppressed where no channel into this interpreter exists (frozen build, code-signed app bundle, pip-less or PEP 668 externally-managed python):transcribe_unsupportedmakes the page show an unsupported notice instead of a command that cannot succeed.bundled_interpreteralso prevents any Homebrew/Winget/Apt decoder advice inside a desktop app. A platform with no prebuilt recognizer wheel is not pip-actionable; the availabilitydetailon/api/stt/statusnames it.ffmpeg_missingremains independent ofavailablefor diagnostics, because a corrupt payload can leave a provider ready while an uploaded WebM cannot be decoded. The decoder itself is no longer a shell command on a host whose distribution packages no ffmpeg:GET /api/stt/statuscarries anffmpegobject (present,source= bundled|system|store,auto_fetch= available|unsupported|bundled, the gateway'sos/arch, and the livedownload),POST /api/stt/ffmpeg/downloadfetches the pinned upstream executable into the digest-verified store at<data home>/models/ffmpeg/, and_ffmpeg_install_commandsreturns[]rather than anechoof the ffmpeg.org URL when nothing is actionable. On a fetch failure the page hands the failure to a chat session through the existing prefill hand-off; see stt-streaming for the trust model, which the store does not widen.transcribeadditionally rendersAwsConsentGate service="transcribe", which is the only way the paid-service grant is recorded (deliberately no CLI verb). - Endpoints:
GET/PUT /api/config/stt,GET /api/stt/status,POST /api/stt/prepare,POST /api/stt/prewarm,POST /api/stt/ffmpeg/download,POST /api/stt/transcribe,GET /api/ws/stt. The live dictation surface these settings drive is specified in stt-streaming.
- Runtime state is a different endpoint from settings.
- System (
/system) — live metrics (1s refresh): CPU %, memory used/total, network RX/TX stat cards; host info with correct Apple Silicon arch detection, load averages; memory, process, network, storage detail cards; uptime ticking every 1s viauseUptimehook (client-side fromstart_time) - Agent Capabilities (
/capabilities;/agentsredirects here) — merged Agents + Capabilities destination, bottom-pinned in the nav.SidePanelLayouttabs in order: Agents (agent → workspace → memory store bindings,KiroCrewAgentsPageembedded), Integrations (MCP), Skills, Hooks, Prompts. The standalone Agent Templates tab was removed: an agent's definition (system prompt, tools, auto-approved tools, MCP servers, skills, guardrails) is now viewed and edited inline in its Template pane on the Agents tab (components/crew/AgentTemplateDetail.tsx), rendered unconditionally. Agent-package install / uninstall now routes through the capability seam (installPlugin/uninstallPlugininproviders/adapters/acp.tsdispatchtype === 'agent'to/api/capability/agents/{install,uninstall}). This replaced the earlier "intentionally NOT offered" stance: the AIM-specific agent-package and update routes were removed, but leavingGET /api/capability/agentsas list-only meant the seam could SHOW installed agent packages and never manage them — an asymmetry with skills/MCP that forced an edition to shadow the core or mount its own routes. Bulk "Update All" remains unoffered (noupdate_*op on the seam). Skills/MCP install/uninstall still route through the capability seam (see Capability Integration); the manager returns human-friendly errors for invalid packages. The MCP registry browser is hidden entirely when no external capability manager is configured (registry → 503); when present it offers click-to-expand descriptions with all detail lines, clickable URLs (DOMPurify-sanitized), a direct Install button, and tier badges attext-[11px]minimum. - Tasks (
/tasks) — redirects to/projects - Projects (
/projects) — autonomous multi-step task execution with left/right split layout (260px sidebar + detail/compose area). Sidebar shows compact project cards with status icons, progress bars, cancel/delete buttons, and "+ New Project" button. Compose area has two modes: ✨ Compose (free-text with refine-to-spec) and 📄 From Spec (paste/upload). SharedAgentSelectorfor agent selection. Plan generation with cancel, auto-polling for planned runs. Selected project detail view (ProjectDetailPage) with Idea/Tasks tabs: Idea tab shows spec content read-only with "Edit in Chat" button; Tasks tab has DAG/Phased view toggle. Action buttons: Execute/Chat/Discard (planned), Cancel (running), Restart/Schedule (completed/failed). Execute stays on project (no navigation). Session storage persistence for mode, input, spec text, and planning state. 3-second auto-refresh polling. - Hooks (
/hooks) — script hook management following standard page layout:PageHeader+StatCardrow (Total, Enabled, Total Runs, Errors) +Card/CardTitle/InfoTipwrapping atable-stripedhooks table withSearchInputfilter. Toggle switches for enable/disable,Badgestatus pills (ok/err/warn),Btnactions (▶ Test, Edit, ✕ Delete). Create/edit form usesCardwrapper withInput, styledselectfor event type,SendBtnfor save. Test results shown in card-like panel below table withBadgeexit status and dismissible stdout/stderr output. - Logs (
/logs) — live gateway log stream via WebSocket (subscribe/unsubscribe viaWsContext), server-side log level control (DEBUG/INFO/WARNING/ERROR buttons). SSE (/api/logs) remains as a secondary transport. - Worlds (
/worlds) — agent world scenes with themed 3D-style environments (neural, wizard, underwater). Decorative page for visual personality.
Agent Selector Component
Shared AgentSelector component (website/src/components/AgentSelector.tsx) used by Chat, Tasks, and Cron:
createPortalrenders todocument.body— escapesoverflowclippingfixedpositioning with viewport-aware placement (flips up if overflows bottom, aligns right edge)z-[9999],max-w-[340px], agent name truncation for long namesSourceBadgesource pills (packageuses the--aimdesign token,projectthe ok token, everything else the muted default)- ARIA:
role="listbox",role="option",aria-selected,aria-expanded,aria-haspopup - Outside-click-to-close via
setTimeout+document.addEventListener - Props:
agents,value,onChange,exclude(filter out agent names) - Agent list refreshes on
refreshTrigger(WebSocket-driven after AIM mutations)
InfoTip Component
Reusable ? button (website/src/components/InfoTip.tsx) for contextual help across all pages:
- Portal-rendered to
document.body— escapesoverflow: hiddenoncard-glowparents fixedpositioning with viewport-aware placement- Solid background (
var(--card)), strong shadow,z-[9999] - Click to toggle, outside-click to close
- Used on: Sessions (Chat), Preferences/Lessons/Cron/Skills/MCP (Overview), AIM/Agents/Context/Usage (Agents), Task Runner (Tasks), Process (System)
MCP Info Button (Chat)
The ℹ button next to chat session titles shows MCP servers for the current agent:
- Calls
GET /api/mcp/active?agent=<name>— for non-kirocrew agents, reads from agent config in~/.kiro/agents/; for kirocrew, reads from global~/.kiro/settings/mcp.json - Per-agent MCP scoping works:
kiro-cli acp --agent <name>loads only that agent'smcpServers - Footer note explains scoping: custom agents load only their own MCPs; kirocrew loads all global MCPs
- Visual: green dot = enabled, gray dot + "disabled" label = disabled
- Count shows
enabled/total
MCP Global Config (~/.kiro/settings/mcp.json)
For the default kirocrew agent, kiro-cli ACP loads MCP servers from the global config. For non-kirocrew agents (e.g. AIM-installed agents), kiro-cli loads only the mcpServers defined in the agent's own config file in ~/.kiro/agents/. The dashboard MCP tab controls the global config used by kirocrew:
disabled: trueon a server prevents kiro-cli from loading it (kirocrew only)disabledTools: [...]on a server prevents specific tools from being registered (kirocrew only)kirocrew-cronandkirocrew-coreare synced to the global mcp.json at gateway startup, fromagent._MANAGED_MCP_SERVERS
kirocrew-cron Notable Parameters
-
cron_add: accepts optionalsilent(boolean),hide_in_chat(boolean, keeps the cron out of the active session list — result still goes to Slack/bell + History),script(Python callable path),command(shell command — mutually exclusive withscript),timeout(seconds — bounds a script/command subprocess only),timeout_secs(seconds — the per-wake execution budget enforced by_execute_with_timeout'sasyncio.wait_for, default 1800, range 1..86400; distinct fromtimeout),strict_schedule(boolean, disables jitter),timezone(IANA name),skip_dates(list of YYYY-MM-DD strings). Atimeout_secsbelow the 1800s (_JOB_TIMEOUT_SECS) reaper floor is accepted and honored by the primary guard, but the reaper force-kill backstop clamps its deadline to at least 1800s (max(min(timeout_secs, 86400), _JOB_TIMEOUT_SECS)), so the response appends aNote:flagging that a stalled loop is not force-killed until the floor. Whensilent=true, results are not auto-delivered — the agent decides when to notify viasend_message. Whenscriptorcommandis set, the job bypasses the LLM entirely (deterministic execution). -
cron_update: acceptsagent_id,timezone,skip_dates,strict_schedule,hide_in_chat,timeout,timeout_secsin addition to existing fields. Loweringtimeout_secsbelow the reaper floor emits the same sub-floorNote:ascron_add. -
cron_trigger: on-demand execution of a specific job by ID. Returns{ok, name}. Non-blocking (fires viacreate_task). -
cron_list: returns a compact one-line-per-job summary by default — id, name, status, schedule, next-run, kind (script/command/agent), optionalagent/channel/last=<status>/err=<preview>/result=<preview>extras, message preview (<=80 chars), last_error preview (<=200 chars), last_result preview (<=120 chars). Full bodies are intentionally omitted so the response stays under ~30 KB for 50-job registries (the LLM tool-call budget would otherwise drop calls on large registries —_render_cron_list_compactinmcp_cron.py). Callers opt back into the legacy multi-line format withverbose: true(regression-safe — byte-identical to the pre-change shape, including the[kind]tag andlast error/last resultlines), or passids: ["<job_id>", ...]to drill into specific jobs and receive full bodies for matches only (idstakes precedence oververbose). Both modes go throughredact_credentials/redact_exfiltration_urlson every user-supplied string. Sanitize-then-truncate ordering is enforced for the message, last_error, and last_result previews so a credential straddling the truncation boundary cannot leak as a partial fragment. Schema:validation.CRON_LIST_SCHEMAenforces_JOB_ID_REonidsitems (max 200) and rejects non-boolverbose. -
cron_listthird mode,json: true(_render_cron_list_jsoninmcp_cron.py): a machine-readable payload for a program rather than a reader, and it takes precedence over BOTHverboseand the verbose shapeidsimplies. Per job it carriesid,name,mode,enabled,schedule,every_secs,minimal_context,persistent_session,hide_in_chat, amessagepreview bounded by_JSON_MESSAGE_LEN(400), amessage_truncatedflag, andhistory. The flag exists because a consumer classifies the PROMPT, so a reason to rule out a cheaper mode could sit past the cut — saying the text was cut is what lets the consumer refuse to judge instead of judging on half a prompt. Payload level:scanned,truncated,history_available, andhistory_unavailable_reasonwhen a read failed.- History semantics:
historyis per-job COUNTS (runs,failures,distinct_summaries,noop_runs,same_every_run), folded by_fold_runs. It isNone— never an empty tally — for a job whose history could not be read, because a job with unknown history has NOT been shown to be idle and a consumer must be able to tell those two apart. On a partial read the jobs that WERE fetched keep their counts and only the unfetched ones areNone.cron-historyis bind-masked from every sandboxed agent shell (sandbox._CREW_HIDDEN_LEAVES), so the read happens gateway-side over loopback withX-Internal-Secret(resolve_serving_port+read_local_secret, the same pathcron_triggeruses), and counts rather than run text are what cross that boundary: a count cannot carry a credential. - Bounds:
_JSON_MAX_JOBS(100) caps the job count, and_JSON_BYTE_BUDGET(88,000) caps the payload by MEASURED serialized size, assembled incrementally so a record can be shed without re-serializing. The byte bound is not redundant with the count bound:json.dumpsescapes a non-ASCII character to a six-character\uXXXX, so 100 jobs with 400-character CJK prompts serialize to ~296,800 characters againstMAX_RESPONSE_LEN(100,000) — andsanitize_responsetruncates with a blind tail slice that appends its notice OUTSIDE the JSON grammar, so an over-budget payload would not parse at all. At least one record is always kept, so a single oversize job is reported rather than silently dropped.truncatedis true when either bound dropped a job.
- History semantics:
-
Auto-inject: when
persistent_session=True(andhide_in_chat=False), cron results are auto-injected into a linked dashboard chat slot (cron-{job_id}), and that slot is filed into the job'schat_folder_idwhen it is minted — see Run Filing into a Chat Folder above. The slot is auto-created on first delivery for persistent crons — no user action required. Dashboard notifications includemeta.slotfor frontend "Go to Chat" navigation. For dedup-suppressed and silent runs, injection only occurs if the slot already exists (avoids creating slots for suppressed output). Whenhide_in_chat=True, all three injection call sites are skipped, so no slot is created and the notification CTA shows "View last result" (no-slot branch) instead. -
Injection is synchronous and hydrates a newly linked slot from the
cron:{job_id}transcript, which means parsing the whole file. Itshistoryparameter is therefore required, with no default: every caller is async, and a default would let one hand that parse back to the event loop.handlers/cron.pyprefetches throughasyncio.to_thread; the gateway's dedup-suppressed and silent paths useprefetch_cron_history, which reads off the loop and returnsNone(read skipped) when the slot is already linked and the injection would therefore not consume it. A forgotten prefetch is aTypeErrorat the call site, not a loop stall in production.
kirocrew-core: local_knowledge_search Tool
Searches the Knowledge Library for relevant content. Escalated from App Store to built-in tool in mcp_core.py.
- Trigger rules: strict — only fires on explicit user signals (e.g. "search my knowledge", "check my docs on X"), not on general questions
- Schema:
LOCAL_KNOWLEDGE_SEARCH_SCHEMAinvalidation.py - Parameters:
query(required string),limit(optional integer, default 3, hard max 5),source_id(optional string — scopes the FTS5-keyword and vector seed legs to one source; graph traversal stays unfiltered. Unknown ids return a guidance message namingknowledge_list_sources, the no-argument companion tool that lists each source'sname — id (N item(s))with active-item counts) - Confidence threshold:
MIN_SCORE=0.012filters noise - Output format: source + content only, no score metadata (~2500 token budget)
- Graceful degradation: returns helpful message when knowledge DB not configured
- Security: credentials/exfiltration URL redaction on all results; SEL audit events for all outcomes (
success,no_results,not_configured) - Store/embedder cache: the
KnowledgeStore(schema DDL + orphan-cleanup migration; the in-memory graph is materialised by the first graph reader, not by construction — seeensure_graph_loaded, #8329) and the embedder are cached process-wide inmcp_core.py(_get_knowledge_search) instead of rebuilt per call. Keyed on a signature ofknowledge.db, its-walsidecar, andconfig.json, so out-of-band dashboard ingestion (which writes the DB/WAL) or a config change triggers a rebuild on the next search; the prior connection is closed on rebuild. Avoids the per-call DDL/migrate/graph-load and the embedder availability probe (which may lazily load the ~700MB in-process model).
Knowledge De-duplication (knowledge/dedup.py)
Collapses the same document ingested from more than one source (e.g. an upload AND a folder-synced copy) down to a single canonical copy so retrieval stops returning duplicates.
- De-dup key: an
items.content_hashcolumn (whole-doc extracted-text sha256, stamped on every chunk of a document at ingest, on both ingest paths; indexidx_items_content_hash). Legacy rows have NULLcontent_hashand fall back to the fuzzy tier. - Two-tier match (
dedup_sweep): Tier 1 -- identicalcontent_hash(name-independent). Tier 2 -- a filename near-match (filename_near_match: date-free normalized stems equal, or a difflib ratio >= 0.9, after stripping(1)/copy/copy ofmodifiers and dates) AND a doc-level mean-pooled embedding cosine >=DEFAULT_FUZZY_THRESHOLD(0.95), compared only between docs with the sameembedding_sig. Dates are not collapsed to a placeholder -- they are parsed out and compared separately: when both filenames carry a date, the closest pair must be within_DATE_MATCH_MAX_DAYS(7) or the fuzzy match is rejected, so distinct instances of a series that share a title (e.g....Apr 2026vs...Dec25) do not collapse. Month-only dates pin to mid-month, so adjacent months land ~30 days apart and are rejected while a few-days drift (including across a month boundary) still matches; extraction uses non-alphanumeric boundaries so underscore-delimited names (Status_Apr_2026) are not missed. The filename gate is an AND on Tier 2 -- it can only narrow matches, never create them. - Document granularity: one folder file (a
folder_file_staterow) for folder sources, the whole source for upload/chat sources. All chunk rows of a document share onecontent_hash. - Priority (
pick_winner): a persistent source (PERSISTENT_SOURCE_TYPES=local_folder/obsidian_vault/quip) beats a transient one (upload/chat); within a class the newestmtimewins; on anmtimetie the oldest-resident copy wins. - Action: the losing document is hard-deleted --
delete_source_cascadefor a whole-source doc, ordelete_items_batch+ itsfolder_file_staterow for a folder file. The file on disk is never touched; there is no soft-supersede and no resurrection. - Triggers: ingest-time after a successful whole-source ingest (
IngestionPipeline._maybe_dedup, gated bydedup_enabled, default on); a sweep at the end of aFolderWatcherscan that ingested/changed files; and a one-time backfill via the CLI/MCP tool. All call the same idempotentdedup_sweep. - Surfaces: MCP tool
knowledge_dedup(applybool, default false = dry-run preview; schemaKNOWLEDGE_DEDUP_SCHEMA) and CLIkirocrew knowledge dedup [--apply](dry-run by default). Both emit SEL audit events and redact credentials/exfiltration URLs in their output.
kirocrew-core: send_message Tool
Sends a message to the user via Slack DM and dashboard notification. Exposed as MCP tool in mcp_core.py, backed by POST /api/send-message in handlers.py.
- Parameters:
text(required),title(optional),blocks(optional),session(optional:"origin"or"slack"),channel(optional),user(optional) - Delivery (default, no session/channel/user): dashboard notification only via
state.notify() - session="slack": Slack DM + dashboard notification
- session="origin": inject into the dashboard session that spawned this cron. Falls through to notification-only if origin is unreachable.
- Security: blocks content is deep-walked through
redact_exfiltration_urls()andredact_credentials()via_sanitize_blocks(), truncated to 50 blocks with recursion depth limit - Response:
{ok: true, slack: bool, session: bool} - Primary use case: silent cron jobs where the agent controls notification timing
Silent Cron Flow
- User (or agent) creates a cron job with
silent: trueviacron_add - Cron timer fires → agent session runs the job message as normal
- Gateway skips auto-delivery (no Slack post, no dashboard notification)
- Agent processes the result, applies judgment (e.g. "nothing changed, skip")
- When the agent decides the user should know, it calls
send_messagewith the relevant output send_message→POST /api/send-message→ dashboard notification (default) or Slack DM (ifsession="slack")
- Toggle syncs to
kirocrew.jsontools/allowedToolsfor consistency - ACP
session/newpassesmcpServers: [](required field);set_modeactivates the agent - MCP server init drain: 10s after
set_mode/set_modelto wait for all servers to load - Drain logs loaded MCP server names at INFO level
Context Window Usage
Retired with the standalone Agent Templates page: the per-session context
window cards and their GET /api/sessions/context endpoint were removed.
Per-session memory and usage remain on GET /api/sessions/memory.
Template deletion
Also retired with that page: the in-UI template delete and its
DELETE /api/agents/detail/{name} route. The design owner's rationale: an
API route with no shipped caller is dead code with a live maintenance
surface, and template deletion is a rare, deliberate act better done on disk.
Deleting a template is documented in src/kiro_crew/docs/agents.md — remove
its JSON file from ~/.kiro/agents/. A crew still bound to the deleted name
does not break at session start: kiro-cli cannot resolve the missing spec and
falls back to the default agent spec, so the session runs with default
behavior until the crew is repointed. If an in-product delete returns, reuse
the removed handler from this repo's history (lock discipline, alias set,
409 reference guard) rather than re-deriving it.
Structured monitors
Structured monitors share AutoNudge's one-record-per-session store and timer
ownership but never enter its legacy prompt-cycle accounting. A record is
identified positively by NudgeLoop.monitor is not None. NudgeLoop.next_due_ts
is the scheduler authority and MonitorState.next_probe_at is its inspection
mirror; every transition writes them together under the service lock before the
off-loop fsync. Active version-1 records re-arm toward that deadline after a
restart. An accepted in-flight wake persists its finite completion-evidence
deadline and resumes that deadline after restart; an older claim with no deadline
is retained, inactive, and blocked. A persisted BUSY claim intentionally has no
completion deadline and resumes its existing next_due_ts retry after restart.
The record also persists its descriptive creation surface independently of its slot
binding. Dashboard and native-channel consumers stamp it at the directive boundary;
Slack-linked dashboard turns carry the channel stamp through queue and recovery paths.
The stamp alone conveys no credential authority. Dashboard mutations reserve the
loop id and bind an exact provider-kind-and-target owner-credential grant in the
sandbox-hidden encrypted-vault directory; creation activates it only after monitor
persistence, updates rebind only an exact protected grant already held by the prior
monitor identity, and removal revokes it. A dashboard update therefore cannot originate
owner-credential authority for a channel-created or otherwise ungranted monitor. A
replacement keeps the prior row and its grant as a rollback candidate until
activation succeeds. If activation fails, the service atomically restores that row
before reporting the failed request, so a transient vault write cannot consume a
stopped monitor and turn an immediate retry into a conflict. Updates likewise capture
the exact prior monitor snapshot under the
same service lock that applies the patch, restore it when the protected grant cannot
be persisted, and refuse that rollback if another mutation has already changed the
committed state. The locked capture includes any earlier concurrent patch that won the
lock, so a failed credential rebind cannot erase an independently committed update.
Grant mutations treat only a missing provenance
record as empty; unreadable, malformed, or partially invalid records fail the mutation
instead of replacing unrelated grants with a newly reconstructed record. Revocation
writes an id tombstone to a separate protected record before cleaning up the active
grant. The tombstone remains authoritative across a cleanup failure and gateway
restart; its unreadable or malformed record denies all grants. An unsuccessful
tombstone write immediately denies that id in-process and is retried by later
credential checks. A later authenticated prepare or rebind clears the tombstone only
after its replacement identity is protected, so an interrupted re-grant stays denied.
If the tombstone itself cannot be persisted, loop removal fails before the
agent-writable monitor row is deleted; the still-bound grant is never orphaned for a
replayed row to inherit after restart. Removal quiesces the loop and cancels its timer
before awaiting that off-loop durable revocation; if revocation fails, the still-stored
row regains its prior active state and timer. A timer therefore cannot fire through the
authorization window, and a failed removal does not strand a live loop without its
clock. A generic AutoNudge replacement follows the
same transaction boundary: it captures whether the displaced structured row owns an
exact active grant, revokes before the combined snapshot, and restores that grant
before re-arming the prior row when persistence fails.
A missing stamp on a legacy record is unknown and denies
ambient owner credentials for every provider outside the explicit GitHub/GitLab
channel allowlist.
Before a spent BUSY claim becomes terminal, its settlement path also clears any
late transport-acceptance marker, so an inactive budget record cannot retain an
accepted turn that no completion timer owns.
Terminal dashboard-notification delivery is recorded against the exact
(id, outcome, stopped_at) generation on the stable outer loop record, with the
current monitor-version bit retained for compatibility. The outer record lets a
gateway persist the acknowledgement without rewriting an opaque future-version
monitor payload. The gateway subscribes before startup can arm timers and then
schedules retained-terminal replay as supervised background work, so notification
persistence cannot delay gateway readiness and a transition during startup cannot
escape both the live observer and replay. A delayed acknowledgement cannot mark a new
terminal generation or replacement monitor as notified.
Future versions also fail closed, are persisted inactive, and are never armed;
their opaque monitor payload remains preserved for a newer gateway. Malformed
current-version payloads load through a valid, inactive
invalid_monitor_record quarantine view while retaining their exact strict-JSON
payload. The loop's active flag and deadline are cleared on the first repair, so
later restarts remain inert without repeatedly rewriting or destroying budgets,
counters, provider evidence, or fields understood by a newer gateway.
Replacing a monitor is committed to the in-memory registry only after its atomic
snapshot succeeds. A persistence failure restores the previous active record and
its deadline-backed timer, so a failed create cannot silently stop the watch or
leave restart state behind the live service.
The gateway imports the controller and provider adapters only after the
AutoNudge feature gate accepts initialization, so disabling AutoNudge keeps
provider clients off the gateway boot path. Dashboard route registration keeps
only monitor models at module scope; the pull-request target parser loads when a
monitor mutation route is actually invoked, not while the disabled gateway
assembles its HTTP application.
MonitorController accepts review_ready pull requests from public GitHub,
GitLab.com or an exact configured self-managed GitLab host, Azure DevOps Services
at dev.azure.com, and Bitbucket Cloud at bitbucket.org. A kind-keyed provider
registry selects the adapter; kind/target mismatches fail before provider I/O.
Azure DevOps Server and Bitbucket Data Center are not supported. The typed
provider probe runs off the event loop behind one shared four-probe concurrency
gate. Missing, unsupported, or untrusted provider CLI resolution is SEL-audited
as denied before its setup error propagates; a resolved CLI must record its
critical invocation event before spawn. Provider CLI resource limits are
installed by the synchronous spawn shim after exec. GitHub retains the shared
same-user policy, which supports stock Homebrew and user-local installs while
refusing other-user, world-writable, project, and workspace paths. GitLab and Azure
monitor probes always require protected, canonical system-owned binaries because
those children receive provider credentials; KIROCREW_PROVIDER_BIN_STRICT=1
applies the same protected resolution to the other shared CLI consumers. The
provider-scoped environment, sandbox, and audit remain defense in depth. Revoked
GitLab hosts and incomplete Bitbucket credentials emit a credential-free denied
audit before returning their terminal authorization or authentication failure,
without provider I/O. Failure to write a denial audit never permits the rejected
probe.
Outside a pod, Azure CLI configuration and extension visibility stays within the protected canonical
HOME/.azure tree and outside agent-writable project and workspace trees, so an
agent-modified extension cannot run with the provider credential. Pods accept only
their credential-scrubbed, disposable KIROCREW_HOME tree. A threaded gateway never runs Python in a fork
child. On Windows the provider child is created suspended,
assigned to the shared Job-object resource ceiling after parentage is confirmed,
then resumed; a live owned child that cannot resume is killed and the probe fails
loudly. GitLab merge requests, Azure pull requests, and Bitbucket pull requests skip
supplemental check and review reads after the primary response reports a terminal
state, so reduced endpoint permissions cannot hide a merged or closed outcome. GitLab
uses the merge request's own head_pipeline, which selects the detached or fork
pipeline GitLab associates with that merge request instead of guessing from same-SHA
project history. A missing or mismatched head pipeline is incomplete check evidence,
never review-ready. Discussion reads retain at most two 100-item pages and perform one
bounded third-page sentinel read, so exactly 200 discussions are complete while a
real overflow remains incomplete. Unresolved GitLab evidence is counted once per
discussion, not once per reply note. GitLab mergeability uses
detailed_merge_status when present.
The legacy merge_status can still prove a conflict on older self-managed instances,
but can_be_merged remains pending because that field does not include approval gates;
only the modern detailed mergeable value proves review readiness.
GitLab's requested_changes detailed merge status maps to the actionable shared
changes_requested review decision. Azure optional reviewers
with no vote do not create a review requirement, while an explicit negative vote
remains actionable. Azure status, policy, and review-thread reads retain at most
100 items each; a response beyond that local bound is incomplete evidence and can
never report review-ready. Azure target normalization accepts provider-legal
Unicode and punctuation in project and repository names, rejects Azure's
forbidden name characters, and emits one percent-encoded canonical URL. An absent or explicit-null
Azure source commit, GitLab head revision, or Bitbucket source commit remains a
pending unknown revision while the provider settles; it is not a malformed provider
response. Bitbucket review readiness considers
only participants whose
role is REVIEWER; authors and other participants cannot create a phantom review
requirement. A paginated Bitbucket status response retains checks_complete=false
in its canonical evidence, independently of task pagination; partial status
evidence cannot report review-ready. Async dashboard and MCP mutation paths await
the shared, asynchronously
refreshed GitLab-host snapshot before target normalization; they never read config
files on the gateway event loop. Azure status/policy labels and Bitbucket build-status
labels become stable opaque identities before canonicalization, so provider display
text cannot enter a structured wake. A GitHub check label that normalizes to no display
text retains its provider-derived state under one stable opaque identity instead of
failing the entire probe. A legacy terminal observation that predates
checks_complete projects as complete, while a present malformed value fails closed.
Generic issue/pull-request comments and
advisory review findings that are not represented by the provider's canonical
review or check facts remain outside its completion predicate; a babysit
objective routes directly to a finite legacy loop instead of asking the
structured tool to represent an evidence scope it cannot enforce. The fallback
recipe sets gate: false so provider-fact gating cannot
suppress cycles that must inspect unobserved comments or advisory feedback.
The prepare-pr recipe likewise disables provider gating and pairs its 80-cycle
poll budget with an explicit 24-hour runtime cap, so the four-hour default does
not truncate its longer review loop.
Legacy monitor MCP calls require positive cycle and runtime caps;
omitted caps default to 24 cycles and 14,400 seconds. The controller persists
changed canonical allowlisted facts,
fingerprint, dedicated latest
classification/reason fields, error counters, decision, and next deadline
before returning. last_observation remains the provider-neutral pull-request fact snapshot; it never
contains the typed fingerprint, status, reason, or summary. A provider error updates
the dedicated latest fields while retaining the last good canonical facts and
fingerprint. NO_CHANGE, RECORD_ONLY,
RETRY_PROVIDER, and all terminal decisions dispatch zero agent turns. Retryable
provider errors use bounded exponential backoff; terminal provider, success,
blocked, and budget outcomes remain inspectable with stable reason codes.
The gateway emits one dashboard notification when a structured record first
reaches success, blocked, budget, or target-unavailable. This reports a terminal
outcome without waking the owning conversation or spending another agent turn.
Each notice names the target pull request and uses the retained stop reason:
merged, ready for review, closed unmerged, and provider or delivery problems
carry different recovery guidance rather than a generic completion claim.
The complete notification body, including its retained target, is URL- and
credential-redacted before it reaches dashboard notification persistence.
An incomplete persisted or cancelled handoff with no typed delivery marker is
normalized onto the bounded BUSY retry path; an untyped dispatcher result fails
closed as unavailable instead of orphaning the durable claim. Transient Slack or
Discord setup failures before provider acceptance also retry as BUSY, while
accepted turns alone enter the completion-evidence window.
Every recovery-relevant probe decision is applied to a staged copy and its
replacement snapshot is fsynced before the live record or timer changes. A
strict NO_CHANGE observation updates only in-process inspection counters and
the next in-process deadline; after a restart the prior durable deadline may
cause one harmless early probe. A failed probe write therefore
leaves the prior deadline, observation, and actionable-claim state intact in both
memory and the restart snapshot.
The same replacement-before-publication rule covers configuration edits,
session-close retirement and rollback, wake claims, typed handoff results, raw
completion accounting, missing-evidence retirement, and unwired-controller
deactivation. Timers are cancelled or re-armed only after the replacement is
durable, so a failed write leaves both the live record and its existing timer
unchanged.
The slot owns this state through begin_close(), cancel_close(), and the
read-only is_closing property; monitor authorization never reaches into its
private storage. The fence is released in the outer close wrapper whenever
cancellation or another abort leaves that slot generation live, so a disconnected
close request cannot permanently make a visible slot unarmable.
Monitor wake instructions are length-checked again after credential and URL
redaction, so a replacement marker cannot expand a valid input into an invalid
persisted record. An active record loaded without a wired controller is retained
as a terminal blocked outcome instead of an inactive nonterminal state.
The controller's pre-probe budget stop uses the same replacement-first ordering,
so a failed terminal write leaves the active record armed and restart-consistent
instead of resurrecting work the live process considered exhausted.
The shared direct budget helper records STOP_BUDGET and retains a finite
completion timer when a transport already accepted the current wake; it never
cancels the only remaining owner of that completion evidence.
Known actionable facts (failed checks or policies, requested changes, unresolved
review threads, and merge blockers) take precedence over simultaneous pending or
unknown facts. For an actionable classification its deduplication fingerprint
contains the known blockers but excludes unrelated pending/unknown check churn;
the full allowlisted canonical observation remains available for inspection.
Before any fresh provider probe or persisted BUSY redispatch, the controller
persists a terminal budget outcome when a positive runtime, completed-turn, or
reported-token limit is already spent; an exhausted monitor therefore performs
neither another provider request nor another action attempt. A previously
accepted DISPATCHED wake still waits for its completion evidence or its bounded
evidence deadline instead of being cancelled by this check. Token enforcement
uses the usage values the provider reports; the public token_usage_known field
states whether every completed turn supplied them, while runtime and completed-
turn limits remain hard fallbacks.
A new actionable fingerprint atomically records last_wake_fingerprint and
wake_in_flight=True before delivery. Concurrent or restarted ticks cannot
dispatch it twice. Immediately before transport handoff, the controller
revalidates that exact claim under the service lock; a user stop or session-close
transition queued while probe persistence was in flight therefore wins and no
transport action starts. Each adapter revalidates the claim again at its final
turn-start boundary: dashboard inside the runner after acquiring any unattended
background-turn permit and completing pre-turn setup, immediately before the
provider stream is created, and Slack/Discord in TurnDriver immediately before
entering the provider stream.
A dashboard handoff is recorded as DISPATCHED only after its queued coroutine
acquires the permit, revalidates the active in-flight (monitor_id, fingerprint) claim, crosses SessionManager's synchronous shutdown gate, appends
the wake, and accepts its completion hook. A background-cap wait that times out
or a shutdown-gate refusal returns BUSY without appending to chat history,
preserving the same claim for the controller's bounded retry; a final
authorization refusal returns UNAVAILABLE. A claim already persisted as
DISPATCHED cannot pass admission again. The accepted dashboard invocation runs
at nested prompt depth so stale-turn, tool-stall, pipe-death, empty-response, and
other dashboard recovery paths cannot enqueue an additional provider turn beyond
the structured monitor's durable action-turn budget.
A runtime acceptance marker survives the DISPATCHED transition until raw
completion settles it. A user stop preserves only a claim carrying that marker;
after a restart, a recovered DISPATCHED claim has no live accepted turn and is
cleared so a replacement monitor can be created.
A stop that lands during channel setup or while a dashboard turn is queued behind
the background cap therefore cannot start a stale action turn. After any setup
await, a structured transport that can no longer construct the claim-bound
completion hook returns UNAVAILABLE before dispatch; it never reclassifies that
wake as an ordinary or legacy turn. Dashboard, Slack, and Discord receive the same
ephemeral,
redacted [Monitor wake] envelope; it is capped at 4,096 characters and is
never stored as loop.message. Check results enter that agent-facing envelope as
status counts only; provider-controlled check identities remain available to the
human inspection surface but never become prompt text. Only Task 2's raw
provider-completion hook clears the claim and its delivery marker, and charges
the action-turn/token budgets. Dispatch or stream return is
not completion evidence. Genuine provider end_turn is successful completion;
the ACP compatibility path marks its fabricated end_turn terminal with
synthetic_completion, and every surface rejects that provenance together with
synthetic timeout/error terminals before shared monitor accounting. A raw
completion received before a transport timeout
remains authoritative and is charged once even when the enclosing stream later
times out. A pre-turn delivery failure retires the record as
target unavailable without charging a turn or immediately retrying the same
fingerprint. Every adapter returns DISPATCHED, BUSY, or UNAVAILABLE.
Webex retains its finite legacy prompt-loop adapter, but structured creation is
refused before persistence because it has no typed dispatch and completion-
correlation contract.
Dashboard crew/member slots refuse an arm from OUTSIDE the session — a cron,
another session, an app, the REST route, the workflow ctx.nudge bridge —
because their ingress is a durable crew queue rather than a direct provider
turn, and nothing may inject automation turns into a member's own thread (the
guard dates from #5184, "expose session monitors to agents", where it was
written to stop outside injection). The
one admitted exception is a self-arm: the arm request came from a turn of
the bound session itself. A member is by definition a self-directed resident
agent, and its own monitor_start / monitor_watch is the normal way it
schedules its next wake; refusing that left the conductor member thread never
woken again while the MCP tool had already answered "requested". Provenance is
the caller's job: authorize_and_add_nudge / authorize_and_update_monitor
take initiator_slot_key, and is_self_arm(slot_key, initiator_slot_key)
admits only a non-blank exact match (blank never matches blank, so a caller that
failed to resolve the target cannot self-arm by accident). Only the
session-directive consumer (session_directive_apply.py) passes it — it applies
a directive to the exact session whose turn produced it, so its binding IS the
initiator — while REST, workflow and app callers pass nothing and stay refused.
A self-arm emits a distinct SEL self_armed outcome beside the ordinary
invoked/success trail, is recorded on the persisted record as
NudgeLoop.self_armed=True (an absent key decodes to False — every pre-field
loop was armed under the old rule), and _fire_dashboard_nudge's pre-provider
mode re-check honours that bit: a self-armed member loop wakes, an externally
armed loop on a slot that has since switched into crew/member mode is still
refused. Because the loop store is agent-writable and this is the one bit that
relaxes a session boundary, the persisted bit is a HINT, not authorization:
_load() normalises a non-boolean value (the string "false" is truthy) to
False with a warning, the fire-time guard compares is True, and it ALSO
requires the keystone-gated trust record — autonudge-self-armed.json inside
the data home's trust directory, written by autonudge_selfarm.py; that
directory is on the sensitive-path floor as a whole, so
agent file tools can neither read nor write it) to name this loop id on this
slot. The authorizer mints the loop id itself, writes that record BEFORE
svc.add (handing the id to the service), and FAILS CLOSED if it cannot
(denies 503 "self-arm record unavailable" with the store untouched — so a
stopped loop the arm would have displaced is never removed for nothing),
because a self-armed loop with no entry would never be allowed to fire; if
the add itself then refuses, the pre-written entry is forgotten. A pre-minted
id already in use is refused as a conflict rather than silently re-minted,
since the record would then name the wrong loop. A boolean true forged into autonudge.json therefore has no
trust entry and refuses. The entry is REVOKED when its loop is removed or
replaced — for EVERY loop leaving the store, not only those whose stored
self_armed bit is true, because that bit is agent-writable and a forged false
plus a restart would otherwise skip the revoke and orphan the entry for a later
id-reusing forgery; a loop with no entry costs one offloaded read — and only
AFTER the store has committed the removal (remove_sync with
persist=True revokes after _save; the persist=False callers revoke once
their own write lands), so a failed save leaves a still-stored loop with the
entry it needs to fire. The monitor_update self-arm path's self_armed grant audit is audit-or-deny like its
invoked audit (503, mutation not run) — a member relaxing the ceiling must leave a SEL record. The rule covers a
LEGACY (prompt) loop's monitor_update too, applied by the directive applier (_monitor_update) rather than the
chokepoint, because authorize_and_update_nudge holds an opaque loop id and no session identity (its REST caller is
user-token gated): a crew/member slot's prompt loop may be revised only by a turn carrying the same self-arm
provenance the structured twin passes as initiator_slot_key (the slot's own human turn or its own delivered
wake, read through the same is_self_arm predicate), and an outside turn is refused with external_arm_refusal
BEFORE the write — message is the instruction every later wake executes, and before this rule a cron injection
on a member slot could rewrite it. The arm path's trust
upsert runs BEFORE svc.add, under a loop id the authorizer reserves first (re-minted until get_by_id answers
absent, so an existing loop's entry is never overwritten) and hands to the service; if the add then refuses, the
pre-written entry is forgotten. Because the entry exists before the loop does, a concurrent removal of the new loop
runs after it and remove_sync's revoke drops it — no orphaned keystone entry for a loop the store no longer holds
is left behind, and no post-add liveness re-check is needed. A record write is a pure UPSERT under an exclusive lock file — it never prunes against a
caller-supplied view of the store, because the authorizer's list_all() snapshot is taken outside that lock and two
members arming in the same second (crew boot) would race it: the arm whose snapshot predated the sibling's svc.add but
committed last would prune the sibling's fresh entry and that armed loop would be refused at every fire. Revocation on
removal is the only path that drops an entry, so concurrent self-arms cannot drop each other's entries and the file is
bounded by the loops armed and not yet removed. The fire-time check applies to
EVERY dashboard loop — prompt loops too, not only the structured/gated ones the
completion hook covers — and a refusal is SEL-audited (monitor_fire /
denied) so an armed-but-never-fired loop has a recorded reason. What the
record does NOT do is authenticate the loop's PAYLOAD: the store is
agent-writable for every loop, so any loop's message can be rewritten
out-of-band — a pre-existing property of the store, tracked as its own design
question (#8980), not a property of the self-arm exception.
Which turns count as "the session's own": the directive consumer supplies
initiator_slot_key for exactly two producers, each named explicitly — a turn
a HUMAN started in this session (producer_is_user_facing, the same
authenticated-human flag the set_project / reset_conversation gate uses),
and the delivered wake of a loop bound to this very slot
(producer_is_self_wake, set only by _fire_dashboard_nudge through
_run_chat(_directive_self_wake=True)): a member's loop firing on the
member's slot is the member keeping itself awake, so the re-arm or
monitor_update it issues from inside that cycle is its own act — the
conductor pattern (loop expires, the wake turn re-arms). On a crew/member slot
that wake only exists because the fire-time guard already proved the loop
self-armed. A cron injection, a sub-agent sharing the slot or an app-driven
turn carries neither mark; those pass an empty initiator and the crew/member
refusal stands (visibly, via the notice row). The wake mark is self-arm
provenance ONLY — set_project / reset_conversation stay human-only, so a
wake can never retarget the slot's project. The arm-time admission check for a self-armed loop requires the
slot's mode to be UNCHANGED between authorization and commit (the external rule
stays "never into crew/member"). Stated decision on later mode changes: a
self-armed loop keeps firing if its slot later changes mode, because the
fire-time guard still requires the SAME slot object (current_slot is turn_slot) and a persistent memory mode, and member slots have their mode
pinned at every writer — so a member→crew switch is not a supported
transition, and a crew slot that armed itself is still the session that asked
to be woken. The asymmetry with the arm-time "mode unchanged" rule is
deliberate: that rule closes a TOCTOU window of milliseconds, not a policy on
future mode changes. Provenance ratchet: is_self_arm trusts the string it
is handed, so the boundary is WHO may hand one over — a test scans
src/kiro_crew and fails when any module other than
dashboard/session_directive_apply.py supplies initiator_slot_key=. That
consumer passes the SESSION'S binding (also on the structured-update path,
never the loop's own key echoed back), and only on the two admitted producers. Incognito and temporary
slots cannot host any persisted automation loop; admission rechecks both the
slot identity and these mode boundaries immediately before arming.
Refusal visibility. The MCP tool answers "requested" over its own pipe
before the directive consumer runs, and gateway-off the consumer's outcome can
only overwrite the transcript's tool_result row, never the model's tool
return. A refused monitor_start / monitor_watch therefore also appends a
notice row (⚠️ Automation loop NOT armed: <authorizer reason>, credential-
and exfil-URL-redacted) through append_and_surface into the session's
transcript, so whoever watches the session sees that no loop exists instead of
a session that believes it armed one. Slot-less callers (a channel transport's
TurnDriver) have no transcript window; the returned string is their surface.
The notice is best-effort telemetry about an already-audited denial and never
turns the denial into an exception.
BUSY persists a short retry for the existing claim without probing or entering
the model, and the retry checks the runtime budget again before dispatch so an
expired claim cannot start another turn; DISPATCHED persists a bounded
completion-evidence deadline. Discord marks the correlation accepted when its
hook-bearing TurnDriver starts; an exception after that boundary still reports
DISPATCHED so the evidence deadline owns recovery. Only
UNAVAILABLE is terminal. Expiry without a raw completion event retains the
terminal completion_evidence_unavailable outcome and clears the claim without
charging or redispatching it. Late delivery or evidence-expiry callbacks cannot
replace a terminal outcome accepted while the handoff was in flight. Cadence
changes update the policy used for the next
future probe but never replace or re-arm a current BUSY retry or completion-
evidence deadline, so repeated edits cannot postpone expiry or runtime checks.
The durable public wake_count increments once on the first DISPATCHED
transition, or on raw completion when it wins the handoff race. BUSY attempts and
retries, restart recovery, duplicate reports, and UNAVAILABLE do not increment
it.
Session close is a retained terminal transition, but history persistence owns
whether that close commits. Retirement preserves any accepted wake claim,
delivery marker, and completion-evidence deadline, and keeps only the bounded
expiry timer needed to release that claim when raw completion never arrives. If
retirement persistence fails, its transactional rollback leaves the active
structured record in place and the generic close rollback never routes it
through legacy add(). If history persistence fails after retirement commits,
the structured rollback restores that exact evidence deadline (or a short
same-claim retry before dispatch) rather than starting a fresh cadence;
the raw completion can therefore still account for the accepted turn exactly
once. The rollback rechecks the caller's slot-generation admission predicate
under the service lock before reactivation, so a concurrent close cannot restore
a monitor after that slot was removed again. A session_close record is not
restartable or replaceable through the dashboard because that close transaction
alone owns whether the tombstone is restored to an active monitor. The slot's
admission fence remains held across every failed-close rollback await and is
released only after the retired monitor and app state have finished restoring,
so a concurrent create cannot be overwritten by rollback.
The stateless MCP surface is monitor_watch, the extended monitor_update,
monitor_inspect, and monitor_stop. Create/update/stop directives carry no
session or loop identifier and are applied to the consumer's authoritative
binding after the same dashboard/Slack/Discord authorization and critical SEL
audit as legacy loops. A structured create or update that is disabled,
unsupported, refused by authorization, or otherwise not applied records a
denied directive outcome rather than a successful application.
The same rule covers a structured stop whose authorization or audit-before-stop
step fails; only an idempotent stop with no structured record is a success.
A create acknowledgement is therefore only pending until the current turn ends; the
operator may confirm application in the dashboard, while the agent can inspect
only from a later user/wake turn. monitor_inspect alone is a direct read: it
requires a
strict authenticated session key and reports unavailable rather than using
ancestor fallback. monitor_inspect and monitor_stop resolve the general
nudge binding, so both reach whichever shape the session's loop holds and a
Webex key valid for finite legacy loops reaches them too. Inspect returns the
structured record when one exists and the legacy presence/cadence reading under
autonudge_loop otherwise; stop routes by the resolved loop's shape. Only
monitor_watch, monitor_update, and the strict-internal structured create/update
path keep the narrower structured resolver, since structured wake delivery is
unavailable on Webex. Internal read failures carry the MCP failure marker and are
audited as failed rather than completed. Changing target/objective clears comparison, decision, and
wake baselines and increments the durable configuration generation; a probe result is
discarded if the captured generation no longer matches. Target/objective edits
are refused while a wake is in flight. A second monitor_watch is likewise
refused with 409 while the existing monitor has a wake in flight, preserving the
old monitor ID until its correlated completion has been accounted. Cadence,
positive budgets, and wake-instruction edits preserve the baseline and generation.
When a channel-origin directive changes a monitor target, the persisted creation
surface ratchets to channel in the same atomic update. A channel can therefore
retarget a dashboard-created monitor without retaining dashboard-only owner
credentials for the newly selected subject.
Budget updates remain sparse through REST/directive authorization and merge with
the current budget record only while holding the service lock, so independent
concurrent edits cannot replace one another with values from stale snapshots.
Terminal records are read-only. A stop routes by the resolved loop's shape, and
monitor_stop and autonudge_stop share one resolve-and-route implementation so
the two never diverge. A structured record is retained with a user_stop
outcome for later inspection; a legacy timer loop is removed (a research-owned
slot is deactivated with a tombstone reason) and leaves nothing behind, so a
stop of a legacy loop cannot be inspected afterward and monitor_inspect then
reports it as not armed. An optional stop reason is credential-redacted, bounded, and
retained separately as user_stop_reason; it never replaces the stable
machine-readable stopped_reason. When the directive is consumed by an in-flight structured action,
the record becomes inactive and terminal immediately but retains that wake's
fingerprint, claim, and delivery marker until the same turn's raw completion
charges its turn and token usage exactly once. If raw completion never arrives,
the user-stop outcome remains terminal and inert: it is never re-armed,
redispatched, or charged from synthetic evidence. Closing a session records
session_close; a close rollback restores only that close-owned transition.
The dashboard treats websocket monitor frames as authoritative over mutation
responses, which may arrive after a newer terminal frame. A removed frame is
a slot tombstone handled before payload validation and deletes either automation
kind from the shared collection, so malformed or stale loop contents cannot
leave a phantom blocked monitor behind.
The dashboard contract is separate from legacy /api/autonudge mutations:
GET/POST /api/monitors, GET /api/monitors/slot/{slot}, PATCH /api/monitors/{id}, POST /api/monitors/{id}/stop, and the sole explicit
revival route POST /api/monitors/{id}/restart. Reads include terminal records.
The dashboard imports its monitor version, bounded limits, provider kinds, and
enum vocabularies from website/src/monitoring/contract.json; a backend parity
test compares that artifact to monitor_frontend_contract(), so a backend
contract change cannot land while the normalizer still enforces stale values.
Every browser monitor route requires the configured dashboard owner before it
reads a caller-selected slot or id, parses a mutation body, or touches the
service. A signed allowed-user dashboard session is not sufficient. A stale
machine-local bootstrap session receives stale_session_reauth; other denied
subjects receive the stable dashboard_owner_required code, and each decision
is best-effort SEL audited without target or provider data. The MCP-only
GET /api/autonudge/session-monitor route is strict-internal: middleware
requires loopback plus X-Internal-Secret, the handler reasserts that trust
before accepting X-Session-Key, and browser-cookie fallback is forbidden.
Both the internal-secret decision and a missing or unsupported session binding
are best-effort SEL audited before the handler returns.
Creation uses the same positive defaults and bounds as MCP; zero is never
unlimited. Dashboard creation is create-only under the service lock and returns
409 if any automation already occupies the slot, even when that record was armed
after the dashboard's last read.
Structured creation never resolves or unlinks the legacy loop stop sentinel, so
a rejected structured replacement cannot disable an existing legacy kill switch.
The dashboard infers the immutable kind from the canonical
target URL; the backend repeats strict kind/host/path validation on create and
target update. Restart rejects an unsupported future monitor version with the
stable unsupported_monitor_version code and does not rewrite its exact retained
raw payload. Restart also conditionally replaces the exact monitor id and
configuration generation read by the request; a concurrent restart or edit
returns 409 instead of silently replacing the winner. It preserves the record's
creation surface and rebinds owner credentials only when the protected store holds
an active grant for that exact prior id, slot, kind, and target. Restarting a
channel-created monitor, or a writable row whose surface or target was forged,
therefore cannot promote it into a dashboard-created owner-credential grant.
Structured AutoNudge
websocket payloads use the owner-only dashboard
channel; legacy websocket frames keep their existing authenticated-client
broadcast. Legacy /api/autonudge list/get routes return a structured record
reduced to presence, cadence, liveness and state, so the FULL record remains
readable only through the owner-gated monitor routes.
Legacy PATCH /api/autonudge/{id} rejects structured ids before mutation. Legacy DELETE routes
a structured id through an owner check and the monitor stop authorizer's
audit-before-mutation ordering. Browser legacy creation is create-only under the
same service lock as structured creation, so a stale empty snapshot cannot
replace an automation armed by another tab. Other legacy callers retain explicit
replacement semantics, but the agent-facing monitor_start and monitor_watch
directives are create-only across both record kinds, with one deliberate split.
Dashboard REST creates keep any-record occupancy: a 409 that never discards a
retained record, active or stopped, preserving inspection evidence. The two
session-directive arms additionally opt into replace_stopped, which narrows
their refusal to records that still occupy the session — an ACTIVE automation,
or a retained stop that is evidence. Only system-imposed stops are re-armable:
approval_stalled, cycle_cap, runtime_budget, structural_terminal, and
terminal-subject records
(a merged or blocked watched subject, including structured BUDGET, SUCCESS
and BLOCKED outcomes). A structural_terminal stop is imposed when a fired
turn is rejected as structurally malformed (a deterministic "Improperly formed
request" answer): re-firing the identical context reproduces it, so the loop is
stopped rather than re-armed, and the remedy is a NEW conversation — which the
genuine-turn reset in chat_runner clears the slot's verdict for — so a later
directive re-arm may displace it. Consumer-recorded stops are preserved on every path —
a manual pause, a structured USER_STOP or SESSION_CLOSE record, and the
auto_research autonudge_stop tombstone its watchdog consumes — and an unknown
stop reason fails closed to preserved. A future-version record is never
replaceable (it belongs to the newer gateway that wrote it), and a terminal
record whose accepted wake still awaits completion evidence keeps its own
wake-in-flight refusal, so an unfinished correlation is never orphaned.
A preserved record is not permanent: the owner ends it by CLEARING it.
POST /api/monitors/{id}/clear is that exit on the structured surface, beside
/stop and /restart, and it is what the session-automation panel's terminal
state presses; DELETE /api/autonudge/{loop_id} does the same for a legacy row
and for a structured one addressed through the legacy route
(authorize_and_clear_monitor — owner-gated, audited as monitor_clear, and
refusing a live monitor, a future-version record, and a wake in flight). The
removal itself re-takes those checks inside the service's own lock hold
(clear_terminal_monitor), because the audit yields the event loop and a
close-rollback restore landing in that window must not be deleted. On the legacy
route the operation comes from a required-to-clear intent=stop|clear and 409s on
a mismatch, so the verb's meaning is the label the user pressed rather than
whatever state the record happened to reach in flight; an ABSENT intent is a
pre-upgrade bundle, which had no clear control, so it is treated as a stop and can
never erase a record. Clearing is the only way the row is removed, so it is what
the re-arm refusal names;
POST /api/monitors/{id}/restart revives the SAME subject and therefore cannot
free the session to watch a different one.
Create-only directives cannot silently replace an active
legacy loop with a structured monitor or discard an active structured monitor's
durable evidence. The generic service update also fails closed for structured
records.
The active chat does not depend on a successful WebSocket handshake to discover
the one-record invariant. It reads both per-slot REST projections through one
React Query entry, fails closed on a malformed, conflicting, or failed snapshot,
and keeps bounded-monitor Start disabled until both reads prove the slot empty.
After a failed read, the popover explains that session monitor state could not be
loaded and offers Retry loading for that slot's existing React Query entry.
Retry does not bypass the creation guard or discard the user's draft; Start
stays disabled until the refreshed reads prove the slot empty.
Snapshot and mutation failures render through the shared ErrorNotice surface.
Agent hand-off remains disabled because navigation would discard the unsaved
monitor draft; snapshot retry and mutation retry remain in the editor.
While a mutation is pending, user-triggered close requests are ignored so a
late failure cannot hide its error and discard the submitted draft. A successful
mutation still closes the editor directly after scheduling the authoritative refetch.
The editor retains drafts and request errors by source monitor (or empty slot) while
the chat selection changes. A delayed failure is therefore visible with its submitted
draft when the operator returns to the originating slot, and a delayed success cannot
close an editor opened for a different slot.
The legacy-create handoff remains available after a read failure because its
server-side create-only lock rejects a concurrent record without replacement.
A live automation frame updates that slot query alongside its Redux upsert. A
removal frame first replaces the cached
slot snapshot with null, then invalidates it and applies the Redux tombstone;
a failed refresh therefore cannot resurrect the removed record, while an
in-flight refresh still keeps creation disabled. A live normalized record still
wins when present; REST supplies cold hydration when WebSocket connect or
reconnect never completes. Monitor mutations invalidate the same query instead
of trusting their response body, preserving live-frame precedence while still
rehydrating the result when no WebSocket is available. The active-slot REST snapshot
remains query-local: it can render while disconnected, but its record or absence never
mutates the Redux collection and therefore cannot overwrite or delete a newer live frame.
The reconnect-wide collection seeds only active monitors; terminal evidence is hydrated
through that per-slot projection and is evicted locally with its session, so retained
server evidence cannot grow the sidebar's process-lifetime slot map without bound.
Terminal reconnect rows refresh an existing per-slot query when neither a live
frame nor a detail-cache write has advanced since the seed started, including
cached absence and predecessor records. The normalized query key identifies the
slot, not the cached monitor ID. They do not create detail queries for unvisited
slots or overwrite newer tombstones.
After a successful legacy stop, the chat clears that slot's REST snapshot before removing
the Redux record, so reopening the editor while WebSocket delivery is unavailable cannot
revive the stopped loop from cached state.
The public monitor projection used by list, slot, WebSocket, and authenticated
monitor_inspect reads includes token_usage_known, the canonical last_observation,
and the dedicated last_observation_status and last_observation_reason_code;
persistence-only and raw provider fields remain excluded.
The projection reconstructs provider-neutral pull-request facts from fixed root
and check-bucket allowlists with a deep copy. Unknown persisted keys are omitted,
and an incomplete or malformed canonical shape projects as an empty observation;
non-string enum fields fail closed before membership checks and cannot abort a
list, WebSocket, or inspect response.
Each check bucket is limited to 100 identities, each identity is limited to 200
characters with a stable digest suffix, and source overflow is retained as
incomplete evidence rather than a false review-ready result.
An otherwise valid observation whose kind differs from its owning monitor also
projects empty.
The SPA represents this compatibility boundary as one discriminated
AutomationRecord: legacy_goal_loop and structured_monitor never share an
implicit shape. One pure normalizer accepts both REST loop records and
autonudge_state WebSocket envelopes. A structured marker is never downgraded
when its version or required fields are invalid; it becomes retained,
non-actionable blocked state instead. The normalizer consumes the durable
wake_count directly, along with the authoritative probe, completed-turn,
provider-error, and token counters. It never infers wakes from current delivery
state.
Redux owns the only mutable automation collection. Initial connection starts the
legacy and structured list reads together, fences their combined result by
connection generation, protects each slot changed by a newer live frame or
tombstone, and folds both through the same normalizer used for live WebSocket
updates. The legacy feed's reduced structured rows are excluded before
normalization because only rows carrying their own message are complete legacy
records. Overlapping reconnects share the in-flight React Query request for each
feed and the original request's live-generation watermark. The reconnect marks
that older response superseded instead of publishing it, then queues one fresh
authoritative seed as soon as the shared request settles; repeated reconnects
collapse onto that one queued refresh. A response that may predate time spent
disconnected therefore cannot persist after reconnection or reinterpret itself
against a newer tombstone.
Channel session keys are normalized through the dashboard's filename-safe
slot-key fold before collection indexing, live-generation tracking, or tombstone
removal, so slack:<ts> state addresses the visible slack_<ts> slot. A live
frame for one slot does not discard unaffected legacy or terminal records from the
seed. When a complete seed omits a cached slot, it tombstones that slot's React
Query detail entry alongside the Redux removal, so the cold snapshot cannot
revive the removed automation. The omission may tombstone only a detail entry whose
React Query update timestamp predates the seed; a mutation or focused REST refresh
that lands while the seed is in flight is newer evidence and survives. Structured
state wins if both responses name one slot.
Chat detail and sidebar select from that collection, so target, status, activity
lanes, and terminal retention cannot disagree because two components cached
different wire records. Slot teardown clears ephemeral conversation state but
does not evict retained automation evidence; only an authoritative automation
seed or tombstone removes that record, so resumed history keeps its terminal
reason and Restart action. The current-version normalizer validates every recognized
boolean, nullable enum, timestamp, observation, action, outcome, and bounded
configuration field, including the active-versus-terminal invariant; a malformed
record degrades to a non-actionable blocked projection. The shared status
derivation covers arm_pending,
active, backing_off, action_running, success, blocked,
budget_stopped, and user_stopped; only an in-flight dispatched wake animates
as agent work. That state alone outranks foreground, workflow, and subagent work
in the sidebar. Scheduled and retained terminal monitors sit below those signals
and below unread, so passive state cannot hide work or suppress the inbox marker.
For a non-empty GitHub observation the normalizer validates the exact canonical
fact categories: blocking review, all four check-name buckets, draft, head revision,
kind, mergeability, review decision, review-thread completeness, pull-request state,
target, and unresolved-thread count. It reads classification, reason, and summary
only from their dedicated adjacent fields, never by interpreting canonical facts as
a MonitorObservation object. Both the root observation and nested checks object
must have their exact version-1 key sets; an extra key makes the record inert rather
than letting an unbounded provider field ride a future wire frame.
The chat composer exposes structured monitor creation and mutation only through
the dedicated /api/monitors routes. The popover opens on the goal loop, not
on the bounded monitor. A bounded monitor accepts exactly one subject — a pull
request URL validated against the four supported code hosts — so opening on it
put every session that is not about a pull request in front of a form it cannot
fill, with the surface accepting an arbitrary objective behind an unlabelled
click. An armed record outranks that default in either direction: a slot holding
a structured monitor opens on the monitor, and a slot running a legacy loop
opens on the loop and is offered no monitor at all, because the alternative
hides a running automation behind the form for the other one. From the goal loop
the bounded form is one press away, under a note stating that a goal loop
invokes the agent every cycle and may be unbounded. That note keeps its
WARN colouring, and on this surface that is load-bearing rather than decorative:
it is the only cost cue the default view carries, so muting it would have
weakened the cue in the same change that put the view in front of every reader.
It also names the surface the way the panel's own title does -- "goal loop"
under "Set a goal" -- because a reader who cannot tell that the loop being
warned about is the screen in front of them is reading a warning about something
else. The switch to the bounded form names its subject rather than its
boundedness, and carries a static underline, since it is the only route there
and a hover-only affordance does not exist on a touch viewport. The button back
from the bounded form is a plain return and carries NO cost warning: it is the
only labelled way back to the default view, and a reader refused to press it
while it read as a penalty, so the cost cue stays where the cost is incurred.
It is also NOT disabled in a crew or member session, unlike every control on
that form that writes: the offer bringing a reader there carries no mode gate,
so gating the exit stranded them on a form they could neither use nor leave
except by closing the popover. It explains that crew and member sessions
cannot host a direct monitor turn and disables create, edit, restart, and
legacy-loop controls for those modes; that explanation renders on BOTH views,
so a disabled goal form is never left without a reason. Stop remains available for an existing
monitor so an operator can always disarm stale state. A new pull-request monitor
starts from the 300-second cadence, 14,400-second runtime, eight-turn,
250,000-token, and three-provider-error defaults. Terminal evidence remains read-only and exposes
Restart as its sole mutation. Creating a different monitor while terminal evidence
is retained is disabled until bulk slot cleanup fences every slot before awaiting,
so an archived slot cannot be repopulated by an in-flight replacement. The form
enforces the backend bounds: cadence 15–86,400 seconds, runtime 1–604,800 seconds,
agent turns 1–8, tokens
1–1,000,000, provider errors 1–20, and at most 1,000 wake-instruction characters.
Each field exposes the same HTML bound and a localized inline error. Updates
track dirty fields, reconcile untouched values from same-monitor WebSocket
frames, and send only the dirty fields, so a stale form cannot move a fresh
deadline or overwrite concurrent configuration. A successful structured mutation
response or legacy editor callback is normalized into Redux only while the selected
automation is still the exact record captured at mutation start; a newer WebSocket
record wins, while a disconnected client sees its accepted write immediately. The
slot REST query is
invalidated in either case. An update parses and
dereferences the target only when that field is dirty, so a non-target edit can
repair an older record whose persisted target is malformed. The URL field names
all four source providers, accepts explicitly allowlisted self-managed GitLab ports,
removes copied-link queries/fragments, canonicalizes the common GitHub /files,
GitLab /diffs, and Bitbucket /diff tabs before submission, distinguishes empty
and malformed URLs from code-host-changing edits, and maps only the backend's
gitlab_host_not_allowed code to localized dashboard.gitlab_hosts setup guidance
that points to ~/.kiro/crew/config.json. The backend's
invalid_pull_request_url code maps to the existing localized URL error; other
malformed monitor fields remain the generic request failure. Provider response
text is never rendered.
Its detail renders target, objective, next probe, wake instructions,
all budgets, latest classification/decision, probe/wake/turn/token/provider-error
usage, and terminal reason. Terminal records are retained and read-only; Restart
is their sole action. Detail evidence uses one column at the narrowest supported
composer width and adds its second column only when the popover has enough room, so
labels and values do not collide on 320-pixel viewports. The legacy goal-loop form
remains reachable behind an
explicit costly label and continues to use /api/autonudge, including its
historical max_cycles = 0 unlimited meaning. Its popover width is viewport-bounded,
its content scrolls vertically, and its numeric controls stack at the narrowest
supported width, so the fallback remains usable at 320 pixels. Bounded and legacy
views share one persistent composer trigger; switching modes replaces only the
popover body, so the entry control never disappears and reappears under a different
icon.
Active-slot hydration reads the legacy record from
GET /api/autonudge/slot/{slot} and the structured record from
GET /api/monitors/slot/{slot}. These snapshots remain in React Query and never
reconcile into the shared Redux collection. A live frame wins whenever present;
an in-flight, successful, or failed REST read cannot overwrite or delete it. A
reduced structured row from the compatibility endpoint is ignored before legacy
normalization, so it neither conflicts with nor replaces the owner-gated record.
Security Enforcement
Denied-command rules are managed in Settings → Security and enforced at the
hooks.py PreToolUse gate, as described in security. Developer →
Config does not expose the retired per-agent enforcement-scope selector.
- MCP server isolation removed — kiro-cli ACP ignores per-agent
disabledoverrides; control is centralized in global mcp.json
Build & Development
- Dev mode:
./dev-fullstack.shstarts the live-source backend plus a Vite dev server on port 3000 that proxies the API to it - Production build:
npm run buildinwebsite/runstsc -p tsconfig.app.json && vite buildintowebsite/dist, which is then staged intosrc/kiro_crew/static/dist/.setup.pyonly COPIES an already-built dist into the wheel; it never runs the build. - Static serving:
server.pyserves/assetsfromdist/assets/(Vite hashed bundles),/staticfrom the static dir (theme images, logo) - Runtime dist resolution: at gateway start
frontend.ensure_dev_dist_symlink()reconcilessrc/kiro_crew/static/distfor a source-tree run — a populated real dir is a no-op, an existing directory link is validated (dangling or index-less targets are replaced), otherwisewebsite/dist(or a legacy siblingKiroCrewWebsite/dist) is linked. The link is created throughplatform_compat.symlink_or_junction: a symlink on POSIX, a directory junction on Windows, since a Windows symlink requiresSeCreateSymbolicLinkPrivilegethat an ordinary account does not hold. Link detection/removal likewise route throughplatform_compat.is_link_or_junction/unlink_link_or_junction—is_symlink()reportsFalsefor a junction, andshutil.rmtreerefuses one. - Missing-bundle behavior: if
static/dist/index.htmlis absent,handlers.py(index()) serves a static "not found" guidance page (restart/rebuild hint). The legacystatic/dashboard.htmlserver-rendered fallback was removed (stored-XSS follow-up); the React SPA is the only shell.
Resilience
- Standalone fallbacks: Memory and Skills APIs work even without
ContextBuilder— handlers create standaloneMemoryStoreandSkillsLoaderinstances pointing to~/.kiro/crew/defaults - Agent config path discovery:
_find_agent_config()searches$KIROCREW_PROJECT_DIR→~/.kiro/crew/project_dirsaved path → package-relative fallback - Static system info caching: Hostname, OS, arch, CPU count, total memory computed once; only dynamic metrics (load, vm_stat, netstat) fetched per request
Tool-Refusal Recovery
When _run_chat refuses a tool call for a recoverable, system-side reason —
a host-gate policy deny (hooks.on_tool_call → TOOL_DENY), the read-only
bash safety gate (is_read_only_bash / unsafe_bash_reason), or a PreToolUse
script hook block (exit 2 → BLOCKED:<hook>:<stderr>, or any other nonzero
exit → BLOCKED:<hook>:<error>, since only 0 and 2 are verdicts a gate can
deliver) — kiro-cli ends the
turn early by emitting the attribution-free marker Tool uses were interrupted, waiting for the next user prompt. Without recovery, the reason reaches only the
dashboard pill and SEL audit log, so the model cannot adapt.
_run_chat records each recoverable refusal as a redacted (title, reason)
tuple in a per-turn _refusal_reasons list. PreToolUse block reasons use the
first non-empty hook STDERR value and fall back to a generic policy-hook reason
for deny-by-default malformed results. When the turn ends — and the user
did not stop it (slot._stopping is false) and no session reset is already
re-queuing — it builds a continuation via context.build_refusal_recovery_prompt,
prepends REFUSAL_RECOVERY_PREFIX, and queue_insert(0, …)s it. The existing
finally-block dequeue loop renders it as an inject message (not a user bubble)
and re-dispatches it on the same session, so the model receives the reason and
can adapt (an allowed alternative, a different tool) or stop on its own with a
stated reason. The synthetic prompt is never mirrored to a linked Slack thread
as user input: the mirror path guards recovery turns out, keyed off is_synthetic_recovery_item.
The continuation leads with remediation guidance when the refusal classifies:
deny_guidance.remediation_for supplies one paragraph per deny class,
de-duplicated so several refusals of the same class contribute it once. Without
it the model receives a reason and no sanctioned path, which is what produced the
reported failure mode — an agent that retries the same shape under a different
reader and then reports the capability missing.
The class is resolved by asking which PRODUCER refused. A producer that names a
catalog rule — the regex tier and the argv-structural self-protection floor lead
with the rule's pattern, the git-publish gated floor with its id — yields the
class from that rule's identity (_RULE_CLASSES, else its category via
_CATEGORY_CLASSES), and nothing else is consulted for such a refusal, including
when the rule has no guidance to offer. A producer that refuses generically — the
un-weakenable fnmatch overlay, the sensitive-path floor, the exfiltration-shape
audit — yields it from anchor phrases in the refusal text plus the subject, which
is the only signal those have. Inferring the class from a rule's REGEX SOURCE
mis-keys ten credential-exfil rules: their patterns name the AWS credential
environment variables, so rules that block moving credentials OUT draw
credential-READ prose inviting the caller to run the command it wanted. The
subject is read on the generic path only, because a command refused for moving a
credential necessarily contains that credential's name. A census in
test_deny_guidance.py fails when a rule in a remediation category resolves to no
guidance, and when any rule's class comes from the anchor scan rather than its
identity.
That guidance is rendered as indented prose, never as - bullets. The
frontend's RecoveryCard counts every bullet in this body as one blocked tool
call (BULLET_RE), so a guidance paragraph written as a list makes one refusal
plus its advice read as two blocked calls. A future edit that tidies the prose
into a list would reintroduce that miscount silently; toolBlockedCard.test.ts
pins the count.
By design there is no retry cap: the model decides when to stop, and the
user's Stop button remains the hard breaker (a stop clears the queue and aborts
the chain). All four permission paths that can process PreToolUse script hooks
(declarative auto-approve, normal gating, trusted/YOLO, and interactive approval)
route BLOCKED: reasons through the same recovery funnel.
Reset-and-Requeue Recovery
A reset-and-requeue path must not replay a turn that already emitted assistant
output or dispatched a tool. chat_utils.build_recovery_requeue() returns the
original request only before any output; otherwise it returns a continuation that
tells the model to resume from restored conversation state without repeating
completed work.
That decision does not depend on why the session was reset, but the continuation
does. The helper takes a required ResetCause — CONNECTION_LOST or
SESSION_BUSY — and returns the continuation for that cause, because the marker
it carries is what the transcript renders: a session that was merely busy must
not be reported to the user as a lost connection. The enum has no default, so a
reset site added later cannot inherit another cause's label by omission.
Every automatic requeue retains kind="synthetic_recovery". Queue consumers use
that structural provenance—not message text—to prevent merging with user input,
continue draining while user turns are held, preserve the pending session-reset
notice, and render the dequeued row as an inject. This keeps a user who happens
to type the same marker ordinary user input. Each continuation starts with its own
marker (CONN_RECOVERY_PREFIX, BUSY_RECOVERY_PREFIX), is folded by the frontend
RecoveryCard, and is not mirrored to a linked messaging surface as user speech.
Design
- OpenClaw-inspired: Space Grotesk + JetBrains Mono, dark/light theme with amber accent
- Tailwind CSS 4 with the theme bridge in
website/src/tailwind-theme.css— design tokens as CSS custom properties, utility classes throughout - Typography scale: body 14px, descriptions/details 14px (
text-sm), labels/buttons/sidebar 13px, badges/captions 12px, decorative icons 10-11px. Minimum readable text: 11px. Code blocks: 13px mono. No text below 10px anywhere. - CSS grid shell: topbar + nav sidebar + content area
- Nav sidebar: collapsible (220px full / 56px icon-only), prominent logo (frameless, radial gradient accent wash, 80px with drop-shadow)
- Animations: rise, slide-up, slide-in, scale-in, shimmer thinking bar, dot-breathe health indicator, brand-glow
Custom Domain
kirocrew setup uses kirocrew.localhost (RFC 6761 reserved, resolves to loopback natively) for http://kirocrew.localhost:5476 (macOS/Linux via sudo tee -a /etc/hosts). Gateway startup also prints the hostname URL for remote desktop access.
On-demand notebook recall
The agent-facing recall route uses the authenticated session's recorded workspace
for Global V1 notebooks; named stores still take precedence. It never selects a
workspace from caller-supplied paths or another active slot. Empty/unreadable
notebook indexes and query failures return markdown_status: index_unavailable
and a notice that missing results do not prove missing memory. Private V2 never
falls back to Markdown/Global. Lessons remain complete at startup, including
consolidated corrections; explicit recall spends its budget on facts and episodes.
Installed skill discovery resolves the signed session's active agent mapping and project. Search/list/read share that scope, return stable full keys, and use offset pagination. Search responses include an incomplete flag while bounded body indexing is still progressing; the MCP renderer makes this visible rather than claiming absence.