The Signal and iMessage channel plugins inferred approval prompts by
regex-sniffing rendered message text (header matching like 'Exec approval
required', /approve command parsing), violating the transport-only channel
doctrine: approval actions must stay typed until channel encoding.
The typed envelope already existed (channelData.execApproval with
approvalId/approvalKind/allowedDecisions) and every payload-level delivery
path consumed it; the regex paths were redundant re-derivation at the raw
send seam plus restart recovery for in-memory iMessage poll state.
- Signal: delete send-level prompt sniffing entirely; all approval sends
already flow through typed structured-payload or native-handler paths.
- iMessage: sendMessageIMessage takes a typed approvalPrompt binding
(id/kind/decisions) from the native approval handler instead of an
approvalKind flag plus text re-parsing.
- iMessage poller: persist pending poll targets in the plugin keyed store
so restart recovery no longer regex-scans chat history; typed recent-chat
discovery for handle-only DM targets stays. Split poll-target ownership
into approval-reaction-poll-targets.ts (max-lines).
- Plugin SDK: remove extractApprovalReactionPromptBinding — beta-only
surface, never in a stable release, so no deprecation window applies;
AGENTS.md now records that rule.
Accepted tradeoff: approval prompts delivered by a pre-upgrade process are
not rediscovered from chat text after restart (<=24h transient state;
persisted reaction bindings and event-driven tapbacks still work).
* feat(ui): surface approvals passively and redesign the approval card
Approvals no longer auto-open the centered modal: the queue is reachable
only through the sidebar attention chip, while the owning session shows
the inline card and other sessions surface via the session-row shield
icon, agent badges, and the chip. Deletes the inline-vs-modal exclusion
machinery (modalApprovalQueue, inlineApprovalId, forceShowAll) and makes
modal dismissal close the view instead of denying the active request.
Card: severity now drives the accent color instead of a table row,
plugin/agent render as header chips, the session key moves behind a
collapsed Details disclosure (modal only), and low-value exec rows
(Resolved/Security/Ask) collapse into the same disclosure. The codex
app-server bridge stops duplicating the session key into description
text; the envelope already carries it.
* fix(ui): gate settings Escape on the approval dialog's recorded open state
ClawSweeper caught that shouldIgnoreSettingsEscape still inferred an open
approval dialog from queue non-emptiness; with passive approvals a pending
queue no longer implies a visible dialog, so settings would swallow Escape.
The exec-approval element now records dialogOpen as a fact and the guard
reads it.
* refactor(line): replace nine-marker prompt DSL with typed rich messages
Delete the LINE plugin's double-bracket marker language (quick_replies,
location, confirm, buttons, media_player, event, agenda, device,
appletv_remote) and its parser. Portable interactions now flow through the
existing presentation-block seam (renderPresentation, matching Discord and
Feishu); LINE-specific cards ride closed channelData.line schemas mapped to
the existing Flex renderers. Prompt section shrinks to four capability
lines and explicitly de-fangs marker text. Removes the stale
assertion-safety baseline entry for the deleted parser.
Production LOC net -69, tests net -433. Suite: 510/510 green.
* fix(line): declare rich message schema dependency
* fix(line): satisfy rich message type checks
* docs(line): mark card fragments as partial
A LINE push made exactly one attempt, so a transient provider or transport
failure dropped the reply even though retrying was safe to do. Retrying alone
would have duplicated a send LINE already accepted, so every push now carries an
X-Line-Retry-Key and reuses it across attempts: LINE answers a replayed key with
409 and the accepted request's sent messages, which resolves to the original
delivery instead of a second message.
Retries follow LINE's documented policy - server errors and transport failures
only, never 2xx, 409 or any 4xx - and run through the shared channel API retry
runner in strict mode. Replies stay single-attempt because LINE offers no retry
key for them.
* feat(voice-call): add sessionScope "main" for main-session call routing
Inbound and outbound calls can now share the configured agent's main
session instead of a dedicated voice session. The new scope resolves
through the existing explicit-key canonicalization path, honoring core
session.mainKey and global-scope aliasing. The inbound webhook path now
forwards coreSession like every other resolver call site.
* style: format events.test.ts
* test(voice-call): split events.test.ts under the max-lines cap
* docs(config): correct compaction mode help text default claim
applyCompactionDefaults has written mode: "safeguard" for unset configs
since dd1b08b3e8, and docs/concepts/compaction.md documents that. The
schema help string still told operators to 'Keep "default"' as if it
were the shipped default — prompt/config text contradicting shipped
behavior. Align the help string with the actual default.
* test(qa-lab): tolerate the residual sleep race in stalled-probe bound
The stalled-versions-probe test asserted sleepImpl is never called, but
the inner AbortSignal.timeout (clamped to remainingMs) and the outer
deadline timer race at ~timeoutMs: when the inner timer fires first the
loop legitimately takes one residual sleep before Date.now() crosses the
deadline. Flaked on CI shard changed-extensions-config-27 for an
unrelated help-string PR. Protect the real invariant — bounded exit with
at most one deadline-clamped sleep — instead of the timer-ordering
artifact.
* test(qa-lab): type the stalled-probe sleep mock parameter
check-test-types rejects indexing an empty-tuple vi.fn mock call
(TS2493); give sleepImpl its real (ms: number) signature.
* fix(scripts): use the interactive flag directly in virtualization probe
no-unnecessary-boolean-literal-compare (type-aware lane) rejects
'(options.interactive ?? process.stdin.isTTY) === true'; both operands
are boolean, so use the expression directly. Broke check-lint on main
via 55240929f5a; fixed in this landing PR per the broken-CI default.
The bound-thread webhook persona path swallowed every pre-dispatch
failure (deleted webhook, revoked token, rate limit) and silently fell
back to the plain bot send. The fallback is intended — persona delivery
is best-effort — but the failure was invisible, so a broken webhook
binding degraded every reply in the thread with no operator signal.
Warn with the error before falling back. Post-dispatch failures still
rethrow (webhookSelected) to avoid duplicate sends.
The ACPX lazy runtime proxy optional-chained every forwarded hook and
fabricated success when the resolved runtime lacked one: doctor() returned
{ ok: true }, getStatus() returned {}, and setMode/setConfigOption/
prepareFreshSession silently no-opped. Combined with the session-reset
service calling prepareFreshSession through two raw optional chains, an
unregistered or incomplete backend made reset preparation a silent no-op:
the reset appeared to succeed while the backend kept resuming the old
conversation.
Contract decision: the SDK-level AcpRuntime type keeps optional hooks
(third-party backends legitimately omit capabilities, and core consumers
gate on presence), but every runtime the ACPX extension resolves
implements the full surface. A new CompleteAcpRuntime type in the
extension makes those hooks required for proxy-resolved runtimes, so an
absent hook is a compile error instead of a fabricated runtime success.
The now-dead legacy runTurn->startTurn adapter (~190 LOC) is deleted with
its tests; the proxy forwards startTurn directly.
The session-reset-service call sites consolidate onto the control-plane
owner helper tryPrepareFreshManagerRuntimeSession, which now records a
visible outcome for every skipped path (backend not registered, hook not
supported) instead of returning silently; that also covers the same
silent skip in manager.close-session.
Regression tests fail pre-fix: proxy hooks reject on contract-violating
runtimes instead of fabricating success, and skipped fresh-session
preparation is recorded.
* refactor(ai): give transport streams an honest writer type
* test(ai): use canonical transport stream fixtures
* fix(ai): preserve partial-less stream deltas
* fix(agents): allow required-preflight native Codex compaction
Required reply-preflight compaction on a Codex app-server-backed session
returns the intentional `ok: true, compacted: false` "codex app-server owns
automatic compaction" no-op because the preflight caller never passes
`allowNonManualNativeRequest`. The reply/preflight path then misclassifies
that successful skip as a failure and throws, dropping the user's turn with
"Context is too large and auto-compaction could not recover this turn." The
equivalent CLI path was fixed by #88207; this is the second, unpatched caller.
Route required-preflight through the existing private
`compactAfterContextEngine` harness capability (which already passes
`allowNonManualNativeRequest: true`) by adding a typed
`nativeCompactionRequest: "required_preflight" | "after_context_engine"`
origin on `maybeCompactAgentHarnessSession` and the Codex compact bridge.
The non-manual skip guard is bypassed for preflight, so Codex actually
compacts the thread.
A binding change between the initial read and the native request is a
stale-binding race, not a benign skip. For `required_preflight` (and the
non-manual CLI path) it now surfaces as the canonical recoverable
`stale_thread_binding` failure so the queued harness falls back to the
context engine instead of treating an uncompacted `ok: true` result as a
completed turn. A genuine post-context-engine request may still skip,
because the context engine has already compacted. Required-preflight is also
the one scoped exception to the model-locked terminal rule: missing or stale
Codex thread bindings recover via the shared context-engine fallback while
the persisted harness lock stays intact; other locked failures remain
terminal.
Rebased onto main after #120740 restructured the guarded native compaction
block; the recoverable-binding semantics are reintroduced on the new
structure and scoped by `nativeCompactionRequest` so #120740's
post-context-engine skip behavior is preserved.
Closes#119971.
* test(evidence): commit inspectable required-preflight live proof scripts for #119971
Adds the two live codex app-server proof scripts (binding-race +
locked-preflight) so the redacted terminal traces in the PR body are
inspectable on the exact head. Both drive the real codex binary and real
maybeCompactCodexAppServerSession with nativeCompactionRequest:
"required_preflight"; neither runs in CI (no codex binary).
* fix(agents): scope locked-preflight compaction fallback to Codex
Restrict the required-preflight model-lock exception to the Codex harness
so missing/stale thread bindings in other locked native harnesses (e.g.
Copilot) stay terminal instead of escaping the persisted model-lock
boundary via context-engine fallback. Add a model-locked Copilot
required-preflight regression covering both missing and stale thread
bindings.
* fix(codex): require native preflight compaction
* chore(plugin-sdk): account for native compaction exports
* test(codex): use complete cron authority fixtures
* chore(lint): shrink compaction assertion baseline
* fix(lint): honor root boundary timeout
* fix(lint): extend package boundary timeout
* fix(plugins): verify native compaction owner
---------
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
* fix(ui): surface hidden-pane steer terminal failures globally
Three terminal branches in steer-lifecycle.ts (transport null result,
failed queue-row restore, failed queue-row removal) still gated their
error on itemStillVisible, so a steer that failed after the operator
navigated away parked the error on the queue row with no visible
outcome — the exact invariant #124473 introduced
surfaceChatDeliveryFailure() to protect.
Route all three through the canonical helper and delete the divergent
visibility-only branches. Regression test fails pre-fix
(stash-verified): steer transport failure with the pane hidden now
surfaces the session-named global toast.
* fix(logging): demote per-turn gateway log noise to debug
Live campaign evidence showed three lines dominating operator logs at
info level with no per-turn diagnostic value:
- 'tool policy removed N tool(s)': the policy pipeline runs on every
turn, so this repeated 42x in one session. Demote to debug and delete
the now-dead toolPolicyAuditLogLevel/auditLogLevel plumbing that only
existed to lower diagnostic probes to the level that is now the
default (net -13 production LOC).
- 'codex app-server one-shot cleanup checked shared client retirement':
routine per-attempt teardown detail; demote to debug.
- 'codex trajectory capture requires the SQLite host recorder': static
config condition warned per attempt; warn once per process.
Skipped: the [model-fetch] info carve-out in model-transport-debug.ts is
a named contract (docs/logging.md, #89648) — always-info by design.
* fix(codex): drop test-only trajectory warn-once reset export
Knip's production unused-export gate rejects
resetCodexTrajectoryRecorderWarningForTest — it was a test-only seam in
production code. Reset the process-wide warn-once flag via
vi.resetModules() + fresh dynamic import in the test instead.
* test(cron): wait for backoff re-arm instead of fixed sleep
The 0ms retry timer arms only after async watcher-state persistence, so
'await delay(5)' races it on loaded CI workers (flaked on
checks-node-compact-large-2: spawn called 1 time, expected 2). Replace
both fixed-sleep re-arm waits with vi.waitFor on the spawn count. The
remaining delay(5) guards a negative no-further-spawn assertion after
cancel, where a bounded sleep is the correct shape.
* fix(codex): scope trajectory recorder warn dedupe to session
ClawSweeper P2: the host recorder factory returns null for per-session
target-mapping conflicts, not only static config, so a process-wide
warn-once flag silenced a later distinct session's recorder loss. Warn
once per session (bounded set, cleared past 64 entries) so retries stay
quiet but each newly affected session records its loss. Regression
covers a later distinct session still warning.
The computer.act v1 wire contract is gone, but the naming that survived it
still described a version split instead of the real one: screen-coordinate
execution versus window/element-scoped execution. Both are live rungs of the
same ladder.
- Extract the screen-coordinate half of the 1334-line ComputerActionService
into ComputerScreenActionExecutor (dispatch, typing, scroll, coordinate
mapping, button-hold watchdog, raw CoreGraphics primitives). Moved code is
unchanged apart from threading the queue authority check as a parameter
instead of reaching back into the queue.
- ComputerActionService keeps its name and becomes the coordinator that owns
the execution queue, the permission probe, and the shared error vocabulary.
- Rename ComputerActionServiceV2 to ComputerWindowActionExecutor, isV2Request
to isWindowScopedRequest, isComputerActV2Only to isWindowScopedOnly, and
ComputerActionError.invalidV2Request to .invalidRequest. The emitted
COMPUTER_INVALID_REQUEST: prefix is unchanged.
- cua-computer: v2-actions.ts becomes window-actions.ts, handleV2Act becomes
handleWindowAct, and the stale v1Params local in handleDesktopAct becomes
desktopParams.
- Note at the computer.act idempotency key that its v1 prefix versions the key
composition, not the wire contract.
Behavior-neutral: no logic edits, no new branches, no changed error strings.
* refactor(plugin-sdk): dedupe session-catalog cursor paging into session-catalog-runtime
The acpx Pi and opencode session catalogs carried byte-identical
boundedLimit/encodeCursor/decodeCursor/optionalRawCursor/transcriptPage
scaffolding (~114 duplicated lines each). Move one canonical copy into the
private-local session-catalog-runtime SDK subpath both plugins already may
import, and keep the plugin-named cursor guards as direct aliases.
Net -87 production LOC; no behavior change (error strings, cursor canonical
form, and byte budgets are unchanged).
* refactor(core): replace private sleep() clones with canonical sleep helpers
managed-linux readiness polling now uses @openclaw/retry sleepWithAbort with
ref:false (preserving the clone's timer.unref behavior), and the embedded
agent runner's async-task wait uses src/utils/sleep.ts. Both clones matched
canonical semantics on real inputs (positive integer poll intervals).
The update-managed-service-handoff copy stays: it lives inside a serialized
standalone handoff script (String.raw template) that cannot import repo
modules.
sendMessageSlack special-cased core's silent-reply token before any API
call, returning a fabricated 'suppressed' messageId. Silent-reply
stripping is owned by core auto-reply normalization before payloads
reach outbound — no sibling channel transport has this check, so a
literal NO_REPLY sent through the message tool delivered everywhere
except Slack. The check predates the extension extraction (it moved
verbatim in 8746362f5e) and is duplicate policy.
Delete the check, its sentinel mint (the only one in the repo), and the
receipt filter for it; tests now pin sibling-parity delivery instead of
the suppression.
Preserve RFC 5322 angle-address emails without weakening namespaced tag stripping, and keep the iMessage security projection aligned.
Co-authored-by: Aria Ghasedi <drariaghasedi@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>