* 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.
* fix(gateway): refresh provider usage asynchronously
* fix(ui): report a stalled provider-usage refresh on Model Providers
The page observed the incomplete-usage marker but discarded the exhausted
outcome, so once the retry budget was spent it rendered ordinary provider
cards with no usage and no explanation — indistinguishable from providers
that report no usage at all. Keep the outcome and render the warning the
Usage page already owns, reusing usage.providerUsage.stalled rather than
minting a Model Providers key so no locale baseline churns.
A user-initiated refresh now restarts the retry budget. The notice tells the
operator to refresh, so the button has to hand back attempts to spend; only
the forced path resets it, or the budget could never exhaust.
Also fixes tsgo:core:test on the current head: createStore's inferred literal
had no usageStats, so the run-bookkeeping case could not stamp it, and
view.test.ts needed the new prop.
Closes the ClawSweeper P2 at model-providers-page.ts:169-175.
* fix(ui): keep the stalled usage notice when usage.status starts rejecting
loadModelProvidersData turned a rejected usage.status into providerUsage:
null, which the page read as a completed load. observe(false) then reset the
retry budget and cleared the stalled callout, so a permanently broken usage
endpoint rendered as ordinary cards with no usage and no explanation — the
same silent failure the callout was added to prevent. The reset also fired
mid-cycle: one incomplete response followed by one rejection restarted the
budget, so the notice could be deferred indefinitely.
Record the failure at its producer instead of inferring it downstream. A null
providerUsage also means "not loaded yet", and no caller can tell the two
apart, so load.ts now reports providerUsageFailed explicitly and the page
treats a failed read as unresolved rather than resolved-empty.
Found by a Codex review of 417d43b65d.
* revert(gateway): drop the opportunistic model-catalog fast path
It broke two chat.history tests on main — both assert the cold catalog loader
runs exactly once, and reading the prepared snapshot first means it never does.
checks-node-compact-small-10 was red for that reason.
The change was a separate-surface latency fix that this PR picked up in passing,
and the body already offered to split it. Dropping it is the honest resolution:
rewriting main's assertions to accommodate a drive-by optimization would trade
one concern's proof for another's convenience. optional-model-catalog.ts,
server-model-catalog-auth.ts and their test return to the merge-base.
This PR is now only the usage.status non-blocking contract and its clients.
* fix(usage): preserve incomplete retry state
* perf(ui): keep usage capability startup-neutral
* fix(ui): restore provider usage retry convergence
* fix(usage): restore retry and cache invariants
* fix(usage): stabilize provider convergence
* test(ui): exercise provider recovery path
* test(ui): remove stale usage route fixture field
* fix(macos): show provider usage errors
* fix(macos): bound usage retries per menu open
* fix(macos): end usage retries on menu close
---------
Co-authored-by: Josh Lehman <550978+jalehman@users.noreply.github.com>
* fix: prevent dollar-pattern injection in prompt template and approval substitution
Three call sites used String.replace/replaceAll with a string replacement
fed a runtime variable, causing dollar-amp/dollar-1/dollar-backtick
sequences in user-supplied args or approval ids to corrupt the rendered text.
Switch each to a function replacement so the value is treated literally:
- prompt-template-arguments: dollar-ARGUMENTS and dollar-@ substitution
corrupted slash-command args containing dollar signs
- get-reply-inline-actions: bundle command template expansion had the
same issue with normalizedArgs
- approval-reaction-runtime: approval id placeholder rendering mangled
ids containing dollar signs; the iMessage sibling
(extensions/imessage/src/approval-text.ts) already escapes this
* fix: rebase, drop unproven approval rewrite, add dollar regression
Address review: the approval placeholder helper has no canonical
/approve <id> producer in the plugin-SDK manual fallback path, so its
rewrite is unproven - revert it. Keep the two reachable prompt-template
fixes and add an owner-boundary regression covering literal dollar
sequences in dollar-ARGUMENTS and dollar-@ substitution (fails on the
old string replacement form, passes with the callback form).
* test(auto-reply): cover literal dollar bundle command arguments
* fix(agent-core): resolve prompt placeholders in a single substitution pass
* 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(gateway): generic operator roles for non-maintainer access
Adds gateway.roles: named role bundles over a closed capability set —
sessions.others (none/view/suggest/write), an agents allowlist, and an
operator-scope ceiling. Roles are person-level (additive user_profiles.role
column, SQLite stays at v9); users.setRole (admin-only) assigns them. With
no gateway.roles config, behavior is unchanged for solo deployments.
Enforcement is deny-by-default from a host-minted actor identity
(system vs operator+profileId on server-only client.internal, never
accepted from the wire) and covers every entry point: WS RPCs, OpenAI-compat
and Responses HTTP, tools invocation, cron, questions, usage, task
suggestions, session catalog/sharing/reads. The agents allowlist gates both
session creation and run-start on existing sessions. Subagent completion
announce and descendant wake mint explicit system authority so role
boundaries never silently drop parent notifications.
The enforcement surface is expressed through a narrow policy vocabulary
(operatorSessionCap, hasOperatorBoundary, authorizeSessionSharing) rather
than per-handler policy internals.
* fix(gateway): heal PR CI after rebase onto main
- Break import cycles: extract GatewayOperatorRoleActor leaf contract; merge
session-group-mutation-targets into session-sharing-target-input.
- Split sessions-suggestions.test.ts (max-lines) into a visibility suite.
- Add users.setRole to the 2026.8 train registry test and regenerate the
Kotlin protocol client.
- Startup UNAVAILABLE gating now precedes session authorization: session
stores are not loaded during startup, so authorization reads would deny
with a misleading non-retryable error.
- sessions.assignOwner keeps its documented visibility-authorized contract
when no operator role caps the caller; view/suggest-capped roles still
cannot reassign foreign session ownership.
- Test stubs updated for main's socket readyState guard (#128144) and the
system-authority arg on channel-native resets.
* test(gateway): chat.send pending-profile dispatch carries its required session target
chat.send requires a non-empty sessionKey at the protocol level; the mutation
pipeline now rejects targetless frames before profile-dependent dispatch, so
the pending-profile test must send a realistic frame.
The shared formatToolResultText helper emitted its trim() result instead
of using it as an emptiness predicate only, so native Mistral and Ollama
requests silently stripped leading indentation and trailing
whitespace from durable tool output (#127587). Chat Completions already
emits the original sanitized text and uses trim solely to detect blank
content; align the shared formatter with that contract. Blank output
keeps the placeholder fallback, error prefix, and omitted-media suffix.
Closes#127587
AI-assisted (Claude); shared + Mistral + Ollama boundary regression
tests fail pre-fix and pass post-fix.
Co-authored-by: Parker Fawcett <Parkerscottfawcett@gmail.com>
* fix(scripts): size the tsdown heap from the build's own cgroup budget
The build heap probe only read the cgroup root (/sys/fs/cgroup/memory.max and
the v1 equivalent). Those files exist only when the process runs in a
namespaced container cgroup; under systemd the budget lives on the process's
own slice, and the v2 root carries no limit at all. So every systemd-managed
build found no limit, fell back to /proc/meminfo MemTotal, and took the full
12288 MB default heap regardless of its actual budget.
Observed on a 15.4 GiB host: openclaw-main-update.service ran tsdown with
NODE_OPTIONS=--max-old-space-size=12288 while its user@999.service slice was
bounded at 5 GiB, reaching 3.2 GB RSS and 6.25 GB peak before the host began
OOM-killing unrelated services.
Resolve the limit from /proc/self/cgroup and walk that chain instead, reading
memory.high alongside memory.max (memory.high throttles reclaim rather than
failing allocation, so a heap above it stalls the build instead of OOM-ing),
and take the tightest bound found. Root paths stay as the container fallback,
and an explicitly injected path list still disables detection.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): resolve the build heap budget from the v1 memory controller too
The slice walk only accepted the unified 0:: record, so a legacy or hybrid
systemd host fell back to the root probe and kept taking host memory. One
resolver now walks both hierarchies leaf-to-root, which makes the static root
list its own depth-0 case and removes it.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): read cgroup controller mounts instead of assuming their paths
v1 controllers can be co-mounted at the cgroup root, where memory.limit_in_bytes
sits under the slice with no per-controller directory, so the hardcoded
/sys/fs/cgroup/memory probe missed the budget and the build took the full
12288MB default. Mount points now come from mountinfo.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): translate cgroup records through the mount root
mountinfo field 4 is the subtree a cgroupfs mount exposes. Under a container
mount the /proc/self/cgroup record stays host-absolute, so walking it verbatim
probed paths below the visible mount and the build fell back to host memory.
Records now translate through the mount root before the walk.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): skip cgroup mounts that cannot represent this process
Falling back to the mount root for a record outside the mount's subtree sized
the build from an unrelated cgroup: an inherited namespace clamped the heap to
the 2048MB floor from a foreign 1GiB limit. Non-representable mounts are now
skipped, and the blind root probe only runs when no memory record exists.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): keep every cgroup mount view, not just the last one seen
One hierarchy can be visible through several mounts and only some expose a
subtree containing this process. Retaining only the last view dropped the
budget whenever a non-representable bind view came later, sending the build
back to host MemTotal.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): decode octal-escaped mountinfo paths before matching cgroups
ClawSweeper P2 on 7e64ad61f7: the cgroup resolver compared mountinfo's mount
root and mount point verbatim. The kernel escapes space, tab, newline, and
backslash in those two fields, so any cgroup mounted under such a path never
matched, the bounded slice was missed, and heap sizing silently fell back to
host memory.
Decode both fields before matching. The decoder lives in scripts/lib beside the
other shared script helpers rather than inline, so the scripts program has one
copy rather than a new ad hoc one.
Regression test fails pre-fix: a v2 mount at "/sys/fs/cgroup\040dir" with a
5 GiB memory.high yields --max-old-space-size=12288 (host fallback) before the
fix and 4352 after.
Follow-up, deliberately not bundled here: src/infra/sqlite-wal.ts,
src/commands/doctor-state-integrity.ts, and src/plugins/bundled-source-overlays.ts
each carry their own private copy of this same decoder. Consolidating all four
into @openclaw/normalization-core is the right end state, but it touches a
shared package plus three core modules and belongs in its own reviewable change.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): resolve cgroup-namespace-relative records to their mount
ClawSweeper P1 on d6fe49dd3f: inside a cgroup namespace /proc/self/cgroup
reports the namespace root ("0::/") while mountinfo field 4 stays the host
subtree the cgroupfs was mounted from ("/docker/<id>"). relativeCgroupPath then
found no prefix match and returned null; because a memory record had already
been seen, the root probe was skipped and the build fell back to host MemTotal.
A constrained container therefore missed its own budget entirely.
That namespace root is exactly what the mount exposes at its mount point, so it
resolves to "/" rather than failing closed.
Regression test fails pre-fix: a "0::/" record against a /docker/2f1a9c mount
root with a 5 GiB memory.max yields --max-old-space-size=12288 before the fix
and 4352 after.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): reject inherited cgroup mount views instead of guessing
ClawSweeper P1 on b4d200c5d2: the previous commit resolved a namespace-relative
record against any mount root, including the inherited views cgroup_namespaces(7)
documents, whose field-4 root reads "/..". Which cgroup such a view exposes is not
derivable from mountinfo, so probing it can size the build from an unrelated
cgroup's limit.
Reject non-canonical mount roots outright. An undecidable view now falls back to
host sizing, which is current main's behavior, rather than silently adopting the
wrong budget.
Regression test covers the "/.." inherited mount: it must yield host MemTotal
sizing, not the 5 GiB limit sitting behind that mount.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): fail closed on namespace-root records against non-root mounts
ClawSweeper P1 on 731d3bbc8e: a "0::/" record does not prove that a mount
rooted at some other subtree exposes this process's cgroup. Resolving that pair
could cap the build heap from an unrelated cgroup's limit.
Return no mapping for it. An undecidable pair now falls back to host sizing,
which is current main's behavior, so the failure mode is a missed optimisation
rather than a wrong budget. The "/.." inherited-mount rejection stays; this
covers the broader ambiguous mapping it did not.
The namespace-relative test is repointed accordingly: an unrelated mounted
subtree must yield host sizing, not that subtree's limit.
Net production change: none (4 lines swapped).
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(build): cap tsdown heap to the real budget and refuse hosts that cannot build
The 2048MB floor was applied on top of a discovered cgroup limit, so a small
container was handed a heap larger than it could honour. Measured in real
cgroups, that does not OOM-kill, it thrashes: a 1500MiB container sat pinned at
its ceiling for 10 minutes with oom_kill at 0, never finished the second of
eleven invocations, and starved every other process on the host.
Cap to the discovered budget, then refuse up front when that budget cannot hold
the build. The threshold is the whole-build peak, not a single pass: a full
eleven-invocation build peaks at 4730MiB, so a 5GiB slice completes while 4GiB
and 2816MiB slices are both killed partway through the third invocation.
The refusal runs before any output is cleaned, so a host that cannot rebuild
does not also lose the build it has.
* fix(build): harden tsdown heap admission
* fix(build): guard the default tsdown plan
* fix(build): preserve runtime-only Docker builds
* fix(build): admit only declaration cache misses
* fix(build): scope heap admission to real budgets
* fix(build): guard direct unified declarations
* fix(build): guard the canonical tsdown config
* fix(build): satisfy cache planning lint
* fix(gateway): release empty orphan leases
* fix(build): cap cgroup budget by host memory
* fix(build): serialize the canonical tsdown config
* test(build): freeze host memory fixtures
* fix(build): honor cgroup v1 soft limits
* fix(build): respect cgroup v1 hierarchy mode
* fix(build): admit unified runtime plans
* fix(build): admit every unified runtime path
* fix(build): collect repeated tsdown filters
* fix(build): ignore cgroup v1 soft limits
* fix(build): use explicit heap override as opt-in
* refactor(build): simplify memory admission
* fix(build): harden constrained build recovery
* fix(ci): prebuild runtime before real CLI shards
* fix(build): honor runtime-only runner environment
* fix(ci): satisfy tooling shard lint
* 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.
* fix(memory): enforce canonical SecretRef resolution
Make Gateway runtime snapshots the exclusive owner of memory SecretRef materialization. Bind embedding credentials and headers to provider-owned destinations, and fence per-agent stale reuse by the provider destination/auth contract.
Release note: Memory search resolves secret references through configured provider policy and keeps embedding credentials scoped to their intended destination.
* fix(lmstudio): preserve resolved memory headers
memory remote headers are already materialized by the Gateway snapshot and now bypass SecretRef re-resolution; provider-owned headers retain canonical resolution; final loopback request proof covers literal preservation and precedence.
* fix(memory): bind stale credentials to auth owners
Resolve memory adapter credential owners from snapshot manifest metadata, conservatively fail cold when metadata is absent, and prove Gemini/Google destination changes plus zero-egress unresolved refs.
* fix(memory): scope compatible embedding credentials
Apply destination ownership to the core compatible adapter while preserving destination-owned credentials and intentionally unauthenticated endpoints. Distinguish loopback principals, consolidate duplicate security tests, and verify the final credential boundary through a live isolated Gateway request.
* test(memory): align destination auth precedence
* fix(memory): bind credentials to query identity
Include URL query parameters in embedding destination ownership so provider credentials and headers never cross tenant boundaries.
* fix(memory): preserve query-bound embedding destinations
* 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
* fix(compaction): re-compact when last entry is a compaction record but context still exceeds window
prepareCompaction short-circuited to undefined whenever the last session
entry was a compaction record, treating it as a no-op signal. That proxy
does not hold: the retained context (prior summary + kept recent turns +
system prompt + injected files) can still exceed the compaction threshold,
so a session can wedge above 100% of its window while the compactor
reports nothing to do. It only becomes eligible once enough new turns
append that the compaction record is no longer last.
Drop the last-entry-is-compaction-record short-circuit so the existing
re-compaction path stays reachable: prepareCompaction continues to walk
the retained tail, and when there is compactable content the prior
summary flows through previousSummary into UPDATE_SUMMARIZATION_PROMPT.
The empty-branch and last-entry-is-reset no-op guards are preserved, and
the existing messagesToSummarize.length===0 guard still returns undefined
when there is genuinely nothing new to summarize.
Related to #120290
* fix(compaction): harden re-compaction state handling
---------
Co-authored-by: Altay <altay@hey.com>
* perf(ai): refresh streamed tool-call argument previews on a length schedule
Every input_json_delta re-parsed the entire accumulated argument buffer
(quote scan, strict parse attempt, repair scan, partial parse), making
assembly quadratic in argument size. A 128KB tool call spent ~1.2s of CPU
on re-parsing alone while blocking token delivery; previews are
preview-only by contract since the terminal parse re-reads the full
buffer authoritatively at content_block_stop.
Refresh previews on a geometric length checkpoint instead: bounded
staleness, linear total work. Applied across every accumulating
packages/ai transport/provider surface sharing the invariant.
(hook bypassed per run-node-tool.sh contract: no local node_modules in
this worktree and pnpm install is out of scope; oxfmt --check green on
all staged files via sibling checkout binary.)
* perf(agents): throttle proxied tool argument previews
* fix(agents): preserve terminal-only proxy tool calls
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>
The first-run gate was paying 5.5s warm / 16.8s cold and retaining roughly 216MB to compute setupComplete. Record the default agent's model-configured fact in the hello snapshot and make the UI redirect decision synchronously from that fact.