* feat(approvals): mint scoped standing grants for cron allow-always
When an operator resolves allow-always for an approval raised by a cron
job's isolated run, the Gateway now mints a scoped standing grant in the
same SQLite transaction that resolves the approval, instead of writing an
unbounded command digest into the JSON allowlist. Subsequent occurrences
of that job execute the exact approved operation (command text, cwd, env
hash) without prompting while the grant revalidates against authoritative
rows: 30-day expiry, revocation, the cron job still existing with the same
config revision, and the minting approval row still holding allow-always
all fail closed back to the normal prompt. Non-cron allow-always behavior
is unchanged.
- New first-use lazy STRICT table operator_approval_standing_grants in the
shared state DB (declared canonically, no schema-version bump; older and
downgraded readers stay valid without it).
- The cron run owner records run -> {agent, job, config revision} in a
process-local registry at run start; exec.approval.request stamps the
cron source and exact operation binding onto the approval at creation,
so nothing is ever inferred from session keys or run ids.
- The gateway exec host consults grants only when policy would prompt;
ask=always, security=deny, mutable file operands, heredoc, strict
inline-eval, and audit-suppression approvals keep prompting. Grant use
updates last_used_at_ms/use_count and emits the exec approval security
event with the grant and minting approval as lineage.
- Abort-wins guard: a run with an abort tombstone never mints.
* feat(approvals): deliver cron exec approvals to approval clients and wait inline
The standing-grant mint path was unreachable end-to-end: #128031 made
cron approval requests register with delivery fully suppressed, so the
shared owner expired them as no-approval-route within milliseconds, and
even a delivered card would have died seconds later when the isolated
run finalized on the approval-pending handoff and authority-close
cancelled the parked approval.
Cron approval requests now carry deliverToApprovalClientsOnly: the
shared delivery owner broadcasts them to connected websocket approval
clients (Control UI, TUI) but skips internal chat approval runtimes,
forwarder/iOS delivery, and turn-source routes, so the per-occurrence
chat spam #128031 removed stays removed. With no approval client
connected, the request still expires no-route into the existing
headless denial. The gateway exec host additionally waits inline for
cron-triggered approvals (the same treatment native chat channels got
in #93918), keeping the isolated run and its delegated authority alive
for the full approval window; cron jobs are single-flight, so at most
one card per job is pending at a time and allow-always ends the
recurrence by minting the standing grant.
Live-proven on a hermetic gateway: card delivered with ~30-minute
window, run waited 72s for the operator click, allow-always executed
the occurrence and minted the grant with no JSON allowlist digest,
the next occurrence ran promptless (use_count 1, no new approval row),
and editing the job failed closed back to a fresh prompt.
* chore(protocol): regenerate Swift models for deliverToApprovalClientsOnly
* fix(approvals): consume standing grants at the spawn boundary; keep node cron headless
Review findings from ClawSweeper on #129526:
- Grant authority is now recorded only at the final effect. The consult
path validates without recording a use and returns a
revalidateBeforeExecution closure (the mutable-file-binding seam) that
consumes the grant immediately before runExecProcess; any invalidation
during awaited pre-spawn work (job edit/delete, revocation, parent
approval reversal) denies with next-step text instead of executing on
stale authority. Regression proves consult leaves use_count at 0 and a
reversed minting approval denies at the boundary.
- Cron approval-client delivery is scoped to host=gateway. Node-host cron
cannot mint or consume grants yet, so it keeps the fully suppressed
headless policy from #128031 instead of raising cards whose allow-always
could not stick; node-host grant support stays a named follow-up.
* test(agents): complete plugin-metadata-snapshot mock factories
Five explicit vi.mock factories for current-plugin-metadata-snapshot.js
exported only getCurrentPluginMetadataSnapshot. Under isolate:false shard
composition the incomplete mock can bleed into siblings that import the
real module — model-resolution-consistency.test.ts failed on CI with
'No withPluginMetadataSnapshotScope export is defined on the mock'.
Spread importOriginal so every binding prod touches stays exported, per
the repo mock-factory rule; only the snapshot getter stays overridden.
* chore: drop accidentally committed pinned swiftlint binary; ignore .build/
scripts/install-swift-tools.sh installs pinned Swift tools into
.build/swift-tools per the lint-swift.sh remediation hint; the 36MB
binary must never ride a commit. Remove it and ignore the directory.
* test(agents): shield model-resolution-consistency from leaked snapshot mocks
The agents-embedded shard still failed after completing five factories:
~20 more test files across the repo mock current-plugin-metadata-snapshot
with incomplete explicit factories, and under isolate:false composition
any of them can strip withPluginMetadataSnapshotScope from this file's
imports. Give the victim a file-local identity mock (importOriginal
spread) that always wins, and revert the static-catalog factory edit that
tipped that grandfathered file over the max-lines cap — the repo-wide
factory completion belongs to a dedicated sweep.
* feat(ui): configure capabilities before session start
Reuse the active-chat Plus menu on new sessions, move Draft into it, and persist admin-scoped tool overrides before the initial turn. Closes#128079.
* test(ui): follow new-session Draft menu
* refactor(ui): reconcile new-session capability ownership
* fix(ui): gate terminal launch on capability overrides
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
---------
Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(nodes): automatic device placement for sessions.dispatch
sessions.dispatch gains autoDevice: true — the gateway selects the eligible
session-host node with the most available worker slots (deterministic
tie-break), retries up to three candidates when a node churns at the
pre-provisioning eligibility fence, and reports the chosen device in the
placement runner projection. Control UI offers Any available node with
actionable disabled reasons. No-eligible-host failures state why.
* fix(ui): break draft-place-state/draft-session-placement import cycle
resolveDraftSessionPlacement only needs four scalar fields; a structural
param type replaces the Pick<DraftPlaceState,...> import that created the
madge cycle.
* fix(ui): keep the devices section hidden when no devices are paired
The Any available node row lives inside the Your devices section; rendering
it with zero paired devices resurrected the section on gateway-only setups.
Gate it on device presence — Connect a machine remains the discoverability
path — and cover both the empty and non-hostable cases.
* fix(gateway): project dispatch runner state through the canonical reader
The dispatch reply no longer synthesizes an available device runner; it uses
the fenced workerPlacementRunnerAvailabilityReader (and disk-space reader)
exactly like session reads, so a node lost after durable provisioning
projects offline consistently. Documents placement.runner.deviceId in the
protocol reference.
* feat(control-ui): show client IP and time zone on the activity identity card
The Activity identity card showed only host and platform, so an operator
looking at a teammate could not tell where that person was connecting from.
Presence already carried a best-effort `ip`, and it was simply not rendered.
Add the client's self-reported IANA time zone to the connect handshake and
presence entry, and render both `ip` and `timeZone` on the device row. The
time zone matters because the connecting address is frequently unusable for
location: connect handling omits `ip` for loopback clients, and tunneled or
Tailscale clients land in private/CGNAT ranges. A browser knows its own zone
regardless of how it reached the gateway.
Both protocol additions are optional fields, so no version bump is needed.
* build(protocol): regenerate Swift models for presence timeZone
When the agent runtime talks to a remote Gateway (gateway.mode "remote" or a
gatewayUrl/gatewayToken override), the message tool withholds the runtime
identity, so a proven-not-sent outbound failure kept its durable retry row
and also surfaced as an error the model would answer by resending — a
duplicate once the queue replayed the row.
- deliver-queue-execute: mark the thrown error recoveryOwnedRetry when the
proven-not-sent row stays replay-eligible (one retry owner per row)
- message.action: additive UNAVAILABLE detail code OUTBOUND_DELIVERY_QUEUED
- message tool: project that error into a non-throwing delivery_queued
result ("queued, will retry automatically, do not resend") and keep the
autogenerated idempotency-key mapping so an identical resend collapses
Validated with unit tests and a real-Telegram remote-gateway E2E
(affected on main: duplicate; fixed: single send, delivery_queued).
Closes#124279
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Adds a generic plugin-declared selectable-context-window surface mirroring thinkingLevels: ModelCatalogEntry.contextWindows + contextWindowDefault through catalog normalization and the gateway protocol, session validation on sessions.create/patch, and a 200K/1M switch inside the Control UI model picker for Claude CLI 5-series models. The Anthropic plugin owns the option mapping: explicit 1m → `[1m]` argv suffix, 200k → bare id + CLAUDE_CODE_DISABLE_1M_CONTEXT=1, omitted → bare id (shipped default argv). Run budgets follow the selection on both CLI and native paths, so a 200K session gets a matching auto-compact window instead of a silent 1M budget.
Review fixes landed in this PR: run-owner prepared-fact plumbing so ordinary replies honor the selection; atomic catalog overlay merge and normalization for the options/default tuple; one-owner tuple reads in the picker; sessions.create key-presence patch semantics; native-run budget capping.
Feature direction and in-picker switch shape by @obviyus (maintainer review).
* feat(control-ui): stream live draft previews in the typing indicator
Multi-identity sessions now show what a teammate is typing, not just that
they are typing: the composer's per-keystroke session.typing sends carry a
bounded tail of the draft (optional preview field, 400 code points max),
the gateway throttle re-emits on changed payloads at 250ms (boolean-only
stays at 1s, trailing edge keeps the latest draft), and the transcript
renders a per-actor bubble with the live text plus a blinking caret.
Actors without preview data keep the three-dot bubble.
Previews are ephemeral presence: never persisted, never part of the
session transcript or model context, excluded from aria-live regions, and
gated by the existing >=2-live-viewers, sharing-role, and incognito
checks. No new config surface.
* chore(protocol): regenerate Swift gateway models for typing preview
* fix(gateway): aggregate typing previews across same-actor connections
A boolean-only session.typing update from a second connection of the same
actor (another tab or device) erased their live draft preview, because
typing liveness aggregated per actor while the broadcast preview came only
from the latest request. Preview aggregation now lives with the connection
aggregation owner: updateTypingConnections tracks per-connection previews
and returns the newest non-empty preview among live connections, so the
broadcast keeps the active draft until its connection stops or expires.
Regression fails pre-fix (event lost its preview field).
The Gateway owns start-or-steer at admission (6515f6a255) and no
client produces expectedRunId anymore (d84a910fc8). The field shipped
only in v2026.8.1-beta.2 - never a stable tag - so it is removed rather
than deprecated. Steer sends resolve the selected session's current
operation; the exact-match branch, the operation|run target identity
discriminator, run_mismatch rejection, and the suggestion producers'
active-run-id selection (with its ambiguity failure) are deleted.
Provider-native turn fencing (Codex expectedTurnId) is unchanged:
the backend-captured runId on the injection target remains.
* feat(gateway): proxy channel conversation avatars
* feat(discord): capture conversation avatars
* feat(slack): capture DM sender avatars
* test(discord): bind guild avatar mock
* feat(ui): render channel conversation avatars
* fix(ui): align sidebar owner fixtures
* fix(gateway): version channel-avatar routes by media revision
A stable per-session URL let AuthenticatedAvatarRouteLoader's blob and
sticky-404 caches pin a mounted row to a stale or blank avatar after the
backing media changed. Append an opaque digest of the media reference so
replacement and 404-recovery change the route identity.
* test(ui): align sidebar owner facet
* fix(ui): keep owner chip until channel avatar loads
A session with a channelAvatarUrl suppressed its owner chip even while the
blob was loading, auth was not ready, or the route 404ed, leaving an empty
lead slot. The chip now rides as fallback content inside the avatar element
and yields only to a usable image. Covers 404 and auth-pending states;
avatar rows keep renderedOwnerId unset so an owner-viewer stays visible in
the facepile.
* perf(ui): keep channel avatar fallback within budget
* perf(ui): lazy-load the channel avatar element
The avatar element and its authenticated blob loader rode the startup
bundle through session-leading-indicator, pushing startup JS 51 B over the
CI gzip budget. Channel avatars are not startup-critical: register the
element on the first avatar row; the owner-chip fallback covers the
one-time upgrade window. Startup JS returns ~1 KiB under the ceiling.
* build(ui): raise startup baseline for channel avatars
CI-measured startup JS is 344379 B against a 343289 B baseline (+1090 B).
The avatar element and blob loader are code-split out of startup (previous
commit); the residual is the sidebar lead-slot render branch and row
plumbing, which cannot be deferred. Baseline updated via
check-control-ui-performance --update-baseline with CI bytes per the
script's contract; well inside the 4096 B ratchet step and 358400 B
ceiling.
* feat(gateway): carry machine class through session moves
Profile move targets accept an optional machineClass with new-session
parity: validated in the protocol, persisted on the durable move intent
(bare nullable column, lazy same-version ALTER), replayed exactly by
restart recovery, and selectable from the Control UI move dialog.
Moving to the current profile with a different class resizes the
session's worker.
* fix(gateway): project placement facts on chat startup
chat.startup and chat.history built sessionInfo without the placement
projection sessions.list applies, so clients merging that row erased a
live worker placement and its move intent — the Runs on Cloud chip
disappeared after any turn until a full reload.
Reuse the canonical ensureColumn helper for the move table's additive
column instead of a hand-rolled PRAGMA read.
* feat(agents): unify agent status into a durable progress_card
Replace the write-only update_plan to-do tool and the fragmented plan
rendering with one durable status artifact per session: progress_card
({plan?, markdown?}, replace-on-write, 8 KiB markdown / 50-step caps).
Cards persist in a lazy-additive session_progress_cards table in the
per-agent DB (no schema-version bump), broadcast progressCard.changed,
and render from the store with exactly one live placement per view
(session rail when visible, else the composer-adjacent bar); transcripts
collapse to one-line receipts, and the sidebar hovercard shows other
sessions' cards inline (markdown + <progress>, DOMPurify allowlist, no
iframes). The three stream-derived plan renderers and their dedup
heuristics are deleted.
Codex runs disable the native plan tool per thread
(tools.update_plan.enabled=false) and receive progress_card via the
dynamic-tool bridge; compaction restore now reinjects the card (steps +
bounded markdown). Card writes still emit the legacy plan stream event so
native apps and channels keep working until their per-platform
migrations. Policy names map update_plan -> progress_card; the shipped
tools.updatePlan=false kill switch is honored.
Net -277 production LOC; -480 test LOC.
* test(agents): regenerate Codex prompt snapshots for update_plan thread-config disable
* chore(protocol): allowlist progressCard.changed for native apps pending card migration
* fix(ci): repair progress card integration checks
* fix(codex): canonicalize native progress cards
* test(gateway): reconcile progress card method order
* test(codex): stabilize native approval fixture
* feat(sessions): stamp agent identity on spawned sessions and return spawn receipts
Agent-spawned sessions recorded the requesting session key as createdActor.id,
so the Control UI creator chip rendered an opaque key. Spawn producers now stamp
the canonical requester agent id; parent-authority validation moves to a new
trusted requesterSessionKey field. projectSessionActor enriches agent actors
with configured identity name/avatar at read time, and visible sessions_spawn
returns a sessionUrl + owner receipt with URL-first acknowledgement guidance.
* feat(sessions): assignable session ownership with owner facet and menus
GitHub-assignee-style ownership: sessions get a mutable owner (defaulting to
the immutable createdActor) stored in additive bare-nullable SQLite columns
with first-use lazy ensure. New operator.write sessions.assignOwner validates
targets, requires an identified caller, authorizes by session visibility, and
records assignedBy/assignedAt inside the write transaction. The sessions agent
tool gains assign_owner; the Control UI adds Assign-to-me/Assign-to menus in
sidebar rows and chat headers, renders the effective owner chip, and the
creator facet/filter now keys on effective owner. Sharing authority stays
anchored on createdActor.
* feat(sessions): record session participants and stack them in the owner chip
Records every distinct external prompter (human profile/channel sender, or a
requesting agent) per session in an additive session_participants table at the
turn-admission boundary — best-effort, deferred, never blocking the turn; the
session's own agent and viewers are never recorded, capped at 32 per session.
The session row projects a bounded participants list (owner excluded) plus a
total count with the same actor enrichment as owner/createdActor. The sidebar
chip becomes a pair-stack when others have prompted (owner front, one peeking
participant or +N behind), the chat header shows the full facepile, and an
authenticated involvingMe list filter adds an Involving-me sidebar predicate.
Participant projection is excluded from logical-session CAS equality so display
history never invalidates session writes.
* fix(sessions): identify built-in agent tool callers for owner assignment
The sessions tool's assign_owner dispatched through the in-process synthetic
client, which carries neither a signed agent-runtime identity nor a human
profile, so agent-initiated reassignment always failed with FORBIDDEN. The
tool now captures its trusted requester agent identity and carries it across
in-process dispatch as internal client state (never wire params); the handler
derives assignedBy as signed runtime identity, then trusted agent-tool caller,
then authenticated human. Live-verified end-to-end on a dev gateway.
* fix(ci): split oversized session modules and refresh prompt snapshots
Split the max-lines offenders at concept boundaries for session equality, tool overrides, and protocol owner schemas. Remove the redundant Number conversion from the node:sqlite participant count. Refresh prompt snapshots after drift from the sessions and sessions_spawn tool description updates.
* fix(ci): restore solo-mode chip suppression and conform new method descriptors
Solo-mode root cause: owner-assignment submenu options reused the permanent owner-chip custom element, so hidden menu avatars were counted as attribution chrome. Menus now use viewer avatars while gateway-gated owner chips remain exclusive to collaborative sessions.
Conform sessions.assignOwner to the 2026.8 descriptor and append-only advertised-method inventories, and regenerate the Swift and Kotlin protocol surfaces.
Keep historical v15/v14 fixtures frozen by stripping the new owner columns; the existing range already excludes the participant table. Replace the new raw SQLite schema probes with synchronous Kysely queries.
Clear max-lines by splitting the organizer host contract, pure agent-navigation projections, and ownership/filtering sidebar cases at their concept boundaries.
* fix(ci): integrate ownership series with latest main surfaces
Wire the sessions-page assign-owner action, merge capability imports, narrow the navigation export scope, and apply sessions-create formatting.
The owner-presence regression came from hidden assign-owner menu avatars emitting data-viewer-id, so owner and menu chrome now opt out of presence markers while real facepiles retain them.
* fix(sessions): scope the involving-me filter to profile-backed participants
Session participant history mixed channel-native sender ids with authenticated Gateway profile ids, so involving-me missed real sessions and could accept numeric collisions.
Record the actor_source namespace at each producer, carry it through the internal SQLite projection, and match authenticated viewers only against profile-backed human participants. Legacy NULL sources fail closed for filtering, while channel ids remain available for display.
* build(ui): raise startup budget baseline for session ownership surfaces
Ownership chips, assignment menus, and the participant stack add ~0.7 KiB
gzip to the startup path; CI compression landed just over the previous
baseline+tolerance. Hard cap (350 KiB) unchanged.
* refactor(sessions): drop raw NULL projection for the lazy actor_source column
The Kysely guardrail rejects typed raw sql snippets outside allowlisted
boundaries; select the lazily-ensured column only when present and let the
row projection treat its absence as unknown/legacy.
* build(ui): refresh combined startup baseline