Adds an `on-exit` cron schedule kind: a job fires once when a watched command/process
exits, via gateway ProcessSupervisor exit watchers. Covers CLI (`--on-exit`/`--on-exit-cwd`),
tool/protocol schema, RPC list-filter, Control UI + macOS read-only display, SQLite
round-trip, and origin-aware wake routing. Restart-safe one-shot (persists completion
before firing); platform-aware shell; bounded watched-command execution.
Squashed from 22 iterative commits for a clean rebase onto current main.
Per-session, TTL-bounded, revocable bearer grants (mcp-grant-store) let an external/interactive
harness reach the gateway's scoped MCP loopback tools without the cli-backend's process-global
token. A grant is a lower-trust boundary: it binds the session server-side AND fail-closes on every
caller-supplied context header (x-session-key plus message-channel/account/current-channel/thread/
source-reply/event-kind), so a grant holder can neither scope-shop the session nor spoof
delivery/action context into scoped tools or the message tool. New attach.grant/attach.revoke
operator methods mint/revoke grants and return the loopback MCP config. Owner/non-owner cli-backend
path unchanged.
#91499 auto-stamps the creator's tool surface as a default toolsAllow cap
on agentTurn cron payloads whenever the creating session is tool-restricted
(a narrowing allow-policy or an explicit deny). CLI backends cannot enforce
a runtime toolsAllow — cli-runner/prepare.ts rejects any defined allow-list
— so every scheduled agentTurn that resolves to a CLI backend (e.g.
claude-cli) fails to start. This silently broke per-thread scheduled
continuations on CLI backends.
A CLI backend is not a runtime tool-policy boundary: it runs with its own
configured tool set, as the operator, on the local machine, and refuses a
runtime allow-list outright. An inherited default cap is therefore
unenforceable on a CLI backend. Decide at run time, where the backend is
known:
- Flag the default. capCronAgentTurnToolsAllow stamps toolsAllowIsDefault
when it fills in the creator surface because the cron requested nothing
(or a bare "*"). An explicit narrowing or empty allow-list is a real
per-cron restriction and carries no flag.
- Drop only the default, only on CLI. The run-executor drops a flagged
default in the CLI branch and lets the run proceed. An explicit per-cron
restriction (no flag) is deliberately passed through, so prepare.ts still
fails it closed and surfaces that the requested policy needs an embedded
runtime. Embedded runs are untouched and keep the full cap enforced.
- Persist the flag. New nullable cron_jobs.payload_tools_allow_is_default
column (additive ensureColumn migration + codec read/write) so the
decision survives a gateway restart, plus toolsAllowIsDefault on the
gateway-protocol agentTurn payload schema — the stamped payload is
otherwise rejected by the contract's additionalProperties:false.
- Preserve the flag across updates. A no-toolsAllow update (reschedule,
prompt edit) no longer carries the stored default forward as a literal
value — that routed it through the explicit-narrowing branch, stripped the
flag, and re-broke the job on CLI after the next restart. The default is
re-derived (flag intact); an explicit restriction is still carried forward
unflagged.
Net policy: on CLI only the unenforceable inherited default is relaxed;
explicit per-cron restrictions still fail closed; embedded backends are
unchanged.
Tests: run-executor drops the flagged default but propagates an explicit
restriction on CLI; cron-tool stamps/clears the flag across create and
update and preserves it across a no-toolsAllow update; store round-trips the
flag (and its absence) through SQLite.
Not covered: agentTurn crons created during the regression window carry a
flagless toolsAllow and remain fail-closed on CLI until recreated or updated
with an explicit toolsAllow.
Discord consumes the dispatch verbose-progress visibility getter the same way
Telegram does: while the durable lane is delivering commentary as standalone
messages, the ephemeral progress draft skips its preamble lines so commentary
renders exactly once. Covered by an active/inactive regression pair.
The CLI parser already emits tool result events (name, toolCallId, isError,
sanitized result), but the runner bridge dropped them, so CLI-backed runs had
no durable tool record under verbose while embedded runs did. The bridge now
forwards result events, and both runners feed a summary tracker that renders
the same formatToolAggregate line the embedded runner emits (meta captured
from the start event args), plus the tool output block when full verbose
output is enabled. Delivery rides each runner's existing tool-result route, so
verbose gating, ordering ahead of the final answer, and the Telegram durable
routing all apply unchanged.
With streaming on, the dispatcher diverted tool-kind payloads (including the
new durable commentary messages) into the ephemeral progress draft, where they
were discarded when the final answer arrived - so verbose runs lost their
progress record whenever streaming was enabled. While the durable verbose lane
is active (per the dispatch visibility getter), tool payloads are now sent as
real standalone messages and the draft yields its commentary lines; tool/plan
draft lines keep the draft since they have no durable counterpart. Reasoning
lane and tool status reactions are unaffected.
When verbose progress is enabled, preamble item events now flush as durable
standalone progress messages through the same delivery path as tool summaries,
instead of living only in ephemeral channel streaming drafts. The latest text
per item id is buffered so snapshot-style producers send one message per item;
the buffer flushes when the producer moves on (next item, tool event, block
reply, or final reply) and drains before the final answer.
Verbose runs also force commentary classification on (commentaryProgressEnabled),
so inter-tool text routes to the commentary lane rather than being folded into
the final answer text.
Dispatch additionally exposes a live verbose-progress visibility getter via the
new onVerboseProgressVisibility reply option, so draft-rendering channels can
route progress to the durable lane while it is active.
Addresses review:
- Reorder the new GET and DELETE branches so rejectsBrowserLoopbackRequest()
runs BEFORE bearer auth, matching the POST path — a browser-Origin loopback
request is now rejected (403) before auth, preserving the loopback Origin
boundary even for unauthenticated browser requests. Added focused tests:
browser-Origin GET and DELETE with no bearer return 403 (before auth).
- The new transport tests now read the loopback owner token from
getActiveMcpLoopbackRuntime().ownerToken instead of resolveMcpLoopbackBearerToken,
so they don't depend on that helper's import (which current main's test file
no longer carries).
Addresses review feedback on the Streamable HTTP transport:
- Keep the loopback server stateless: drop the advertised Mcp-Session-Id header
(the server owns no session lifecycle, so advertising a session id clients
would echo back was misleading). Resolves the stateless-vs-sessionful concern.
- Add DELETE /mcp as an auth-gated 200 no-op (Streamable HTTP teardown), so
clients that send DELETE on close get a clean 200 instead of 405; Allow now
advertises GET, POST, DELETE.
- Keep the GET/SSE notification channel (the actual fix for the 'still
connecting' hang) auth-gated and browser-origin-rejected.
- Add focused gateway tests: GET 200 + text/event-stream, GET 401 (no auth),
GET 403 (browser origin), DELETE 200, DELETE 401, unsupported 405 with the
correct Allow, and POST stays stateless (no Mcp-Session-Id).
The DELETE path acknowledged Mcp-Session-Id without validating,
terminating, or expiring anything. Since the loopback server is
stateless (session ID is cosmetic for spec compliance), return 405
instead of pretending to support session teardown.
The MCP loopback server generates config with `"type": "http"` for
Claude Code, but only handled POST requests. Claude Code's Streamable
HTTP client sends GET to open an SSE notification channel before
completing initialization. The 405 rejection on GET caused Claude Code
to hang at "still connecting" indefinitely.
- Accept GET /mcp with bearer auth, return text/event-stream (idle SSE)
- Accept DELETE /mcp for session termination (spec compliance)
- Add Mcp-Session-Id header to POST responses (spec requirement)
- Update 405 Allow header to reflect supported methods
Detect text accumulated before tool_use blocks in the Claude CLI
streaming parser and emit it as commentary via a new onCommentaryText
callback. This enables the same commentary progress display that the
Codex backend already provides through preamble item events.
- Add onCommentaryText optional callback to createCliJsonlStreamingParser
- Flush accumulated assistantText as commentary when content_block_start
with tool_use type is encountered
- Track last flushed position to avoid duplicate emissions on consecutive
tool_use blocks without intervening text
- Wire the callback in both execute.ts (regular CLI spawn + live session)
and claude-live-session.ts, emitting AgentItemEventData with
kind=preamble and progressText
- Add 3 test cases covering: text before tool_use, empty text before
tool_use, and consecutive tool_use dedup
Commentary lines carry noCompact so the progress-draft renderer does not compact
them like tool lines — assistant prose renders in full, spilling to a new message
at the channel limit rather than truncating mid-sentence.
Inter-tool commentary (assistant text emitted before a tool call, surfaced by
the CLI parser as a stream:"item", kind:"preamble" agent event) landed on the
agent-event bus with no subscriber and was silently dropped: runCliAgentWithLifecycle
bridges the assistant, reasoning, and tool streams to channel callbacks, but the
item/preamble stream had no bridge. Add createCommentaryEventBridge to forward it
to onItemEvent, so CLI commentary reaches the channel's commentary render hook.
This is the CLI-dispatch delivery half of the commentary feature: the parser
emission (claude-cli) feeds the bus; this bridge delivers it to the channel.
Addresses the claude-cli case of intermediate-text-lost (#87326 / #84486).
Add `ownsNativeCompaction` capability to CliBackendPlugin so backends
that manage their own transcript compaction (e.g. Claude Code) can
declare it once and OpenClaw defers instead of fighting or failing.
Today only Codex declares compaction ownership (via the embedded runner
path + agentHarnessId). Claude-cli never reaches that path because it
runs as a CLI subprocess with no harness id set, so the safeguard
summarizer fires and hard-fails the turn.
This PR:
- Adds `ownsNativeCompaction?: boolean` to the backend plugin type
- Propagates it through all 4 backend resolution paths
- In `runCliTurnCompactionLifecycle`, when a backend declares ownership
but has no harness endpoint, returns a no-op instead of falling
through to the safeguard
- Sets the flag on claude-cli (first adopter)
Codex's existing native-harness path is unchanged: when
`isNativeHarnessCompactionSession` matches, the harness compaction
endpoint is still called as before.
Generalizes the partial fix in #87785 (codex-scoped) to a capability
any backend can opt into.
Add a CLI-runtime-gated bridge in runAgentTurnWithFallback that subscribes
to `stream: "assistant"` agent-events for the current runId and re-emits
them as reasoning content through `params.opts.onReasoningStream`. Mirrors
the assistant-text bridge from #76914 and the tool-event bridge from #80046:
same Promise-chain serialization + drain, same silentExpected gate, same
unsubscribe pattern at success/catch/finally.
The reply lane is untouched -- `onPartialReply` continues to settle the
final assistant text via #76914. The reasoning lane now reflects the
model's live text output during streaming, which is the only "what is the
model producing right now" signal available for claude-opus-4-7 over
claude-cli (Anthropic suppresses readable thinking_delta events on the
wire for opus-4-7; only thinking content_block + signature_delta arrive).
The bridge is gated on isCliProvider so API/native runtimes that already
get reasoning content from real thinking_delta events do NOT double-receive
text_delta as reasoning.
Tests cover:
- Forwards assistant agent-events to onReasoningStream with correct text
- Respects silentExpected (heartbeat / NO_REPLY runs don't emit)
- Does not fire on the API/native runtime path (gate works)