* feat: cap completion tokens to remaining context window for chat-completions
* test: cover dynamic completion budget for kimi and openai-legacy
* fix: leave compaction budget uncapped to avoid one-token summaries
* fix: add hookCount to plugins-selector test mocks
* feat: add shell mode (`!`) to the CLI
Add shell mode, letting users run shell commands directly from the prompt
with `!`. Output streams live into the transcript, supports backgrounding
(ctrl+b), cancellation (Esc / Ctrl+C), input queuing while running, and
enters the conversation context with resume support.
* feat(kimi-code): show shell mode label on editor border and add tip
Render a "! shell mode" label on the top-left of the editor border while the editor is in `!` bash mode, so the active mode is visible at a glance. Also add a rotating toolbar tip (`! to run a shell command`) to surface the feature.
* feat(kimi-code): refine shell mode queue, history, and display
- Keep `!` commands out of input history so they never resurface as bare text stripped of their `!`.
- Make `!` commands non-steerable: Ctrl-S skips them (they stay queued to run after the current task) and the steer hint is only shown when something is actually steerable.
- Render queued `!` commands with a `$` prompt and the shell-mode hue so they read as commands, not as text to send to the model.
- Echo executed shell commands with a `$` prompt instead of `!`.
* fix(kimi-code): sanitize shell output and harden rendering
Captured shell command output can contain terminal control sequences (colours, cursor moves, alternate-screen switches, OSC hyperlinks, carriage-return spinners, bells). pi-tui's Text passes strings straight to the terminal, so any unhandled sequence was executed by the terminal and fought with pi-tui's own cursor control, producing the blank-screen-plus-leftover-characters mess after running commands like pnpm dev or a nested TUI.
- Sanitize CSI (incl. private modes), OSC, single-char ESC and C0 control chars (keeping newline and tab) in both the finished/resume view (previously unsanitized) and the running tail.
- Make the sanitize, format, and ShellRunComponent render paths never-throw, and cap the live running buffer, so a misbehaving command cannot crash the TUI.
- Dispose transcript children on clear so ShellRunComponent's timer is released on /clear or session switch.
* fix(kimi-code): render shell command echo with $ instead of sparkles
The shell command echo is a 'user' transcript entry, so UserMessageComponent prefixed it with the USER_MESSAGE_BULLET (sparkles), producing 'sparkles $ command'.
Add an optional bullet override to UserMessageComponent / TranscriptEntry and set it to an empty string for the shell echo (both live and resume), so the '$ command' content sits at the leading column where the sparkles marker used to be. Normal user messages keep the sparkles bullet.
* fix(kimi-code): enter shell mode when pasting a !-prefixed command
The bash-mode trigger only handled the single ! keystroke, so a pasted !cmd was inserted as literal text in prompt mode and submitted as a normal message.
After pi-tui inserts pasted content, detect an empty-prompt buffer that now starts with !, switch to bash mode, and strip the leading ! so the buffer holds only the command, matching the typed ! path.
* fix(kimi-code): restore shell mode when recalling a queued command
recallLastQueued() dropped the queued item's mode, and the Up-arrow recall only restored the text. A queued ! command (queued while another command runs, which resets the editor to prompt mode) therefore came back as a normal prompt and was submitted as a message instead of a shell command.
Return the full QueuedMessage from recallLastQueued() and restore editor.inputMode (plus the onInputModeChange sync) from the recalled item's mode.
* feat(kimi-code): use violet as the shell mode color
Replace the claude-code-style magenta/rose shellMode token with a violet that is distinct from plan-mode blue, the user role amber, success green, error red, and the teal accent.
Custom themes that omit the token fall back to this new default via the base+overrides merge, so existing custom themes keep working unchanged.
* chore: refine the shell mode changeset
* docs: document shell mode
Add a Shell mode section to the interaction guide and list the ! and Ctrl+B shortcuts in the keyboard reference, in both English and Chinese.
* test(protocol): include shell events in volatile classification check
shell.output and shell.started were added as volatile event types for shell mode; update the snapshot test's volatile-type list and count accordingly.
* fix(agent-core): surface shell command failure reason with no output
When a ! shell command fails without producing stdout/stderr (non-zero exit with no output, timeout, spawn failure), the failure reason lived only in the tool result's output and the TUI showed '(no output)'. Fold it into stderr so the live view and replay show what went wrong.
* fix(kimi-code): decode CSI-u ! to enter shell mode
In terminals with the Kitty keyboard protocol (VSCode integrated terminal, Kitty), pressing ! arrives as a CSI-u sequence, so the raw normalized === '!' comparison never matched and shell mode could not be entered by typing !. Decode with printableChar before comparing, matching every other printable-key check in the TUI.
* fix(kimi-code): do not steer while a shell command is running
Ctrl-S steers queued input into the running turn, but a shell command is not an agent turn, so steering during streamingPhase === 'shell' would launch a turn before the command output is recorded. Keep Ctrl-S a no-op during shell runs; queued messages stay queued.
* fix(agent-core): escape bash tag delimiters in shell output
Shell command output is arbitrary text; if it contains a bash tag delimiter such as </bash-stdout>, the recorded pseudo-XML wrapper breaks and replay extracts the wrong slice. Escape the content when wrapping it in agent-core and unescape when extracting during replay, so output survives round-trip intact.
* docs: document the shellMode theme token
The shellMode color token was added to the palette but not propagated to its mirrors. Add it to the custom-theme docs token table, the theme JSON schema, and the custom-theme skill token list.
* feat(agent-core): reset background task deadline on detach
Add a resettable deadline timer to BackgroundManager and let tasks register a detach timeout; when a foreground task is moved to the background, its deadline resets to the background default counted from the detach moment.
Wire this into shell mode so ! commands run with a 3-minute foreground timeout and get 10 minutes once detached to the background, instead of staying bounded by the original 60-second foreground deadline.
* feat(agent-core): lower shell mode foreground timeout to 2 minutes
* feat: honor HTTP_PROXY/HTTPS_PROXY/NO_PROXY for all outbound traffic
Install a global undici dispatcher at CLI startup so every in-process fetch
(LLM APIs, MCP HTTP, web tools, telemetry, sign-in, update checks) honors the
standard proxy variables, and propagate NODE_USE_ENV_PROXY to spawned stdio
MCP child processes. Loopback hosts always bypass the proxy; an invalid proxy
URL is reported and ignored rather than aborting startup.
* feat: support SOCKS proxies via ALL_PROXY
Recognize SOCKS proxies (socks5/socks5h/socks4/socks alias) from ALL_PROXY or a
socks-scheme HTTP(S)_PROXY, routing traffic through a custom undici connector
backed by the socks client (reusing undici's own TLS handling for https).
HTTP(S) proxies keep precedence; NO_PROXY and loopback are honored for the SOCKS
path too. Child stdio MCP node processes honor HTTP(S) proxies via
NODE_USE_ENV_PROXY; SOCKS applies to the main process only.
* fix: address proxy review comments (env masking, child NO_PROXY, nix hash)
- Resolve HTTP(S)_PROXY explicitly via the first non-blank casing so a blank
lowercase var can no longer mask a populated uppercase one (the dispatcher
installed but went direct), and coerce a SOCKS-scheme value sitting in an
HTTP(S) var to '' so it is never handed to EnvHttpProxyAgent.
- Reconcile a child's NO_PROXY override across both casings using the first
non-blank value run through resolveNoProxy, so a per-server config override
is not shadowed by the injected lowercase value, keeps the loopback bypass,
and passes '*' through verbatim.
- Update flake.nix pnpmDeps hash for the added socks/undici dependencies.
* fix(proxy): honor http ALL_PROXY, match port-qualified NO_PROXY, note child Node version
- Honor an http-scheme ALL_PROXY as the catch-all fallback for both http and
https (scheme-specific HTTP(S)_PROXY still wins), so an ALL_PROXY-only setup
no longer installs a no-op dispatcher and connects direct.
- Make the SOCKS-path NO_PROXY matcher port-aware: a `host:port` entry now
matches only that port (with IPv6-safe parsing for `::1` / `[::1]:443`).
- Document that child stdio MCP proxying via NODE_USE_ENV_PROXY only applies on
Node versions that support it (>= 22.21 / >= 24.5).
* fix(proxy): IPv6 + wildcard NO_PROXY and per-server child proxy edges
- Strip IPv6 brackets from a SOCKS proxy host (e.g. ALL_PROXY=socks5://[::1]:1080)
so the socks client connects to the bare address.
- Add the bracketed [::1] to the loopback bypass: undici's EnvHttpProxyAgent
only exempts IPv6 loopback when the NO_PROXY entry is bracketed (it mis-parses
bare ::1). The SOCKS-path matcher normalizes brackets on both sides.
- Match *.domain wildcard (and host:port) NO_PROXY entries in the SOCKS matcher.
- Compute the child stdio proxy env from the MERGED env so a proxy declared only
in a server's config.env also enables NODE_USE_ENV_PROXY.
* fix(proxy): synthesize HTTP(S)_PROXY from ALL_PROXY for child processes
proxyEnvForChild now hands spawned stdio MCP children the resolved
HTTP_PROXY/HTTPS_PROXY (in both casings), synthesizing them from an http-scheme
ALL_PROXY when no scheme-specific variable is set. Node's --use-env-proxy reads
HTTP_PROXY/HTTPS_PROXY (not ALL_PROXY), so an ALL_PROXY-only parent now proxies
the child consistently with the main process. Shared resolveHttpProxyUrls helper
is reused by createProxyDispatcher and proxyEnvForChild.
* chore(changeset): tighten proxy changeset wording
When the user interrupts running tools or parallel subagents, the tool_result
fed back to the model was a neutral `Tool "X" was aborted` or a weak "stopped by
the user", so the model treated it as a system fault and speculated about
capacity/concurrency limits instead of recognizing a deliberate stop.
Carry a UserCancellationError as the AbortSignal reason from the cancel sites
(Turn.cancel/abortTurn, SessionSubagentHost.cancelAll) through to the message
sites (tool-call settle paths and the AgentTool catches), which now emit an
explicit "deliberate user action, not a system error/timeout/capacity limit"
message. Aborts propagated from another signal (e.g. a subagent's deadline via
waitForCurrentTurn) carry their original reason, so a timeout is not mislabeled
as a user interruption. The telemetry outcome classifier matches the new
"manually interrupted" phrase to keep counting these as cancelled.
* feat(agent-core): add cron ClockSources abstraction
ClockSources splits wall-clock and monotonic time so the cron scheduler
can be driven by an injected/simulated clock without breaking the lock
heartbeat. resolveClockSources reads KIMI_CRON_CLOCK to switch between
system, env-var-backed, and file-backed wall clocks; monotonic time is
always process.hrtime.bigint() and never overridable.
* feat(agent-core): add 5-field cron expression parser
parseCronExpression handles the standard 5-field syntax with the
cron-style dom/dow OR rule. computeNextCronRun uses field-by-field
jumping (not minute scanning) so sparse expressions like '0 12 1 1 *'
stay fast. hasFireWithinYears caps the search at 5 years so syntactically
legal but never-firing expressions ('0 0 31 2 *') return null instead of
spinning forever — required by CronCreate validation.
* feat(agent-core): add deterministic cron jitter
Recurring jobs shift forward by min(10% of period, 15min); one-shot
jobs landing on :00/:30 shift earlier by up to 90s. Offset is hashed
from task.id so reschedules and restarts stay stable. KIMI_CRON_NO_JITTER
disables both branches for reproducible benches.
* feat(agent-core): add CronTask type and cron prompt origins
CronTask matches what gets persisted to tasks.json (with durable stripped).
CronJobOrigin carries coalescedCount and stale so the agent can react to
collapsed fires without separate channels; CronMissedOrigin tags the
boot-time AskUserQuestion path.
* test(agent-core): guard against Date.now() in cron scheduler files
oxlint 1.59 does not support no-restricted-syntax, so the ESLint-style
guard from the plan is implemented as a vitest scan. The four guarded
files (scheduler/persist/lock/jitter) must route every wall-clock read
through ClockSources.wallNow(); clock.ts is excluded because that is
where the abstraction is defined. Non-existent files are skipped so the
guard activates automatically when later commits introduce them.
* feat(agent-core): add cron telemetry event-name constants
Four event names emitted by later commits (cron_scheduled, cron_fired,
cron_missed, cron_deleted) live with the cron module rather than in the
generic telemetry interface so a typo can't drift the metric and the
abstraction stays domain-free.
* feat(agent-core): add in-memory SessionCronStore
Holds cron tasks scoped to a single CLI session — vanish on exit. Phase 2
will add a file-backed sibling that shares the shape. Ids are 8 hex
characters with a collision-retry cap; createdAt is supplied by the
caller's wall clock so the store stays clock-pure (and exempt from the
Date.now() guard for the same reason).
* feat(agent-core): add session-only CronScheduler engine
The scheduler is a pure callback-driven loop: it gets tasks from a
source(), gates on isIdle()/isKilled?(), computes next fire via
cron-expr + jitter, and invokes onFire with the coalesced count when a
task is due. lastSeenAt is in-memory only — coalesce semantics make a
restart skip acceptable, but persisting last-fire would silently swallow
legitimately-due fires. pollIntervalMs=null lets P1.8 disable the
auto-tick timer for bench scenarios.
* feat(agent-core): add CronManager Agent integration layer
CronManager owns a SessionCronStore + CronScheduler and wires them to
the Agent: scheduler.isIdle reads agent.turn.hasActiveTurn, isKilled
reads KIMI_DISABLE_CRON, onFire builds a CronJobOrigin and routes
through agent.turn.steer. Stale flag is computed on read (7-day age,
recurring only, KIMI_CRON_NO_STALE shorts it) so manager doesn't have
to mutate persisted tasks. handleMissed takes a renderer callback so
P2.7 can plug in the AskUserQuestion text without bringing render
imports into Phase 1.
* feat(agent-core): add CronCreate tool (session-only path)
CronCreate validates the expression, enforces the 5-year fire window
(blocking '0 0 31 2 *' typos), caps prompt bytes at 8KB, caps active
jobs at 50 per session, and rejects durable=true until Phase 2 adds the
file-backed store. Manager exposes emitScheduled/emitDeleted so tools
never reach into agent.telemetry directly.
* feat(agent-core): add CronList tool
Read-only tool surfacing every scheduled cron job for the session. Each
record carries id / cron / humanSchedule / nextFireAt / recurring /
durable / ageDays / stale, formatted in the same key: value\n---\n
shape as TaskList. nextFireAt is the post-jitter timestamp so the model
sees what the scheduler will actually fire on. Malformed cron strings
render with null nextFireAt instead of throwing — defends against any
future direct store inserts.
* feat(agent-core): add CronDelete tool
Validates the 8-hex id shape up front and routes deletion through the
manager so cron_deleted telemetry stays consistent with cron_scheduled.
Not-found is reported as an error so the model corrects itself rather
than silently believing the delete succeeded — the next CronList would
still show whatever id was missed.
* feat(agent-core): wire CronManager + cron tools into Agent
Agent gains a public cron field constructed and started in the
constructor. The scheduler's setInterval is .unref()'d so the cron
timer never keeps the process alive, and isKilled (KIMI_DISABLE_CRON)
short-circuits every tick, so eager start is safe.
ToolManager registers CronCreate/CronList/CronDelete next to the
background tools. initializeBuiltinTools runs lazily after the Agent
constructor finishes, so this.agent.cron is already defined when the
tools are constructed.
* feat(agent-core): add manual-tick env + SIGUSR1 bench hook
KIMI_CRON_MANUAL_TICK=1 forces the scheduler into manual-drive mode
(pollIntervalMs: null), and in the same gate SIGUSR1 binds to a
no-throw manager.tick() so bench scripts can advance the scheduler
with kill -USR1 <pid> without a custom RPC.
SIGUSR1 binding is opt-in (rather than always-on) for two reasons:
the auto-tick interval already advances the scheduler, and a CLI with
many subagents would otherwise pile up listeners and trip Node's
10-listener default. Tests cover the env gate, signal swallowing,
listener cleanup, and the no-bind path when the env is unset. The
AgentTestContext harness gains an onTestFinished cleanup so the
auto-started cron manager never leaks across test files.
* test(agent-core): add end-to-end session cron smoke
Exercises the full Agent → ToolManager → CronScheduler stack through
the production CronCreateTool surface. Local-time anchor + injected
ClockSources make coalescedCount=3 deterministic across host
timezones. A second case walks the Create → List → Delete tool cycle
to confirm the three-tool surface composes round-trip.
* test(agent-core): extract shared cron test harness
Pulls the duplicated createAgentStub + createClocks helpers out of the
five cron test files into test/agent/cron/harness/stub.ts. The shared
stub keeps the lightweight-Agent shape (only turn + telemetry surfaces
need to look real) while letting individual tests opt into the
options that mattered locally (hasActiveTurn / steerReturns).
* test(agent-core): trim cron test file headers
Compresses each cron test header to a couple of lines describing what
the file covers. The long rationale blocks were process documentation
(why-this-file-exists, why-stub-vs-real-agent, plan-doc references)
that didn't help anyone reading the test later. The few details that
mattered (local-time anchor, coalescedCount math) stayed inline next
to the code that needs them. E2E test also picks up the shared
createClocks helper instead of defining its own.
* test(agent-core): convert cron tool output assertions to inline snapshots
The 21 multi-field toMatch / toContain assertions across CronCreate /
CronList / CronDelete tests covered the same ground a snapshot would
have but cost more diff churn when a format detail changes. Errors
become single-line snapshots; success outputs go through a small
scrubCronOutput helper that replaces the random 8-hex id and ISO
timestamp with stable placeholders so the snapshot is deterministic
across TZ and run.
* refactor(cron): remove durable flag, env clock source, and enable cron tools in default profile
- Remove the unimplemented durable persistence field from the entire cron stack (types, tools, manager, tests, docs) to avoid misleading the model into promising cross-session persistence that does not exist yet.
- Drop the env:VAR clock source in favour of the file:path source for test/bench control.
- Register CronCreate, CronList, and CronDelete in the default agent profile.
* fix(agent-core): address PR #136 typecheck and Codex review for cron tools
Typecheck:
- Bracket-access KIMI_CRON_* env reads under TS4111 in cron source/tests.
- Add `approvalRule` to CronCreate/CronList/CronDelete tool executions.
- Guard `cron-expr.detectStep` against undefined array elements; cast the
readonly snapshot in session-store.test through `unknown`; import the
missing `ClockSources`/`ContentPart` symbols in manager.test.
- Extend `isReplayUserTurnRecord` switch to cover the new `cron_job` and
`cron_missed` origins so kimi-code stays exhaustive.
Semantics (from Codex review):
- CronCreate re-reads `wallNow()` and re-checks the session cap inside
`execute()`, so manual-approval delays and concurrent prepared calls
can't backdate the schedule or breach the cap.
- One-shot jitter floors the pull-forward at `task.createdAt`, so a
brand-new `:00`/`:30` reminder can't end up before its scheduling time.
- Scheduler coalesce loop reapplies the same jitter as the delivery path
and advances `lastSeenAt` to the last actually-delivered ideal fire;
a not-yet-due jittered slot is no longer lost. One-shot fires always
report `coalescedCount: 1`.
- Manager removes recurring tasks after the first stale fire and emits a
`cron_deleted` event, matching the 7-day auto-expire contract.
- CronList anchors one-shot `nextFireAt` at `createdAt`, so a pending
today's slot isn't rendered as tomorrow.
Tests cover each of the above and a changeset is added.
* fix(agent-core,tui): address deep review + Codex review on PR #136
Closes the deep-review pass and the four Codex review rounds that
followed on the cron tools feature. Consolidated rather than landed
as a series so the PR history reads as one fix wave on top of the
original Phase-1 cron implementation.
## Lifecycle + structural
- Session.close() now stops every agent's CronManager via
`Promise.allSettled` (mirroring `stopBackgroundTasksOnExit`).
Without this the 1s setInterval and its closure-captured
Agent/Session graph leaked on every closed session.
- Agent constructor gates `cron.start()` and the three Cron tool
instantiations on `type !== 'sub'`. Subagents no longer pile up
empty 1Hz timers or duplicate SIGUSR1 listeners under
`KIMI_CRON_MANUAL_TICK=1`.
## Permission + plan-mode parity
- `PlanModeGuardDenyPermissionPolicy` denies CronCreate and
CronDelete during plan mode (CronList stays allowed); matches the
TaskStop precedent.
- `DEFAULT_APPROVE_TOOLS` includes CronList for parity with TaskList
/ TaskOutput so manual-approval mode doesn't prompt on read-only
listings.
## Cron-fire envelope + projector
- Cron fires wrap `task.prompt` in a `<cron-fire jobId=… cron=…
recurring=… coalescedCount=… stale=…><prompt>…</prompt></cron-fire>`
XML envelope (mirrors `notification-xml.ts`). Without this the
`coalescedCount` and `stale` cues documented in cron-create.md
were invisible to the LLM.
- Projector `isInjectionUserMessage` recognises `<cron-fire ` so the
envelope isn't merged into adjacent real user messages.
- TUI `SessionReplayController.renderUserMessage` skips cron_job /
cron_missed origins (matches `isReplayUserTurnRecord`'s exclusion);
resumed sessions no longer render the raw envelope as user text or
miscount cron records toward the replay turn limit.
## Scheduler invariants
- `tick()` only advances `lastSeenAt` / removes one-shots after a
successful `onFire`. A throw in `agent.turn.steer` previously
silently lost the fire; now the next tick re-detects and retries.
- New `getNextFireForTask(id)` on the scheduler (delegated through
the manager) lets CronList render the same instant the scheduler
will fire. Previously CronList computed from `nowMs` and could
report tomorrow's slot while a current-period jittered delivery
was still pending.
## Cron parser + validation
- `parseCronExpression` now rejects non-cron numeric forms via a
`parseCronInt` helper guarded by `^\d+$` — `''` / `'1e1'` /
`'0x10'` / `'+5'` / `'-5'` no longer silently become 0, 10, 16,
5, or `0-5`.
- `nextRunWithinMinutes` bounds search by a wall-time deadline
instead of iteration count. Each iteration can skip a month, so
the old `capMinutes + 10_000` cap let `0 0 30 2 *` walk ~200 000
years before bailing; the new path returns null in microseconds.
- `oneShotJitteredNextCronRunMs` returns `idealMs` (not `createdAt`)
when the pulled-forward time would precede `createdAt`. The old
clamp made an 08:59:30-scheduled `0 9 * * *` fire on the very next
tick — ~29 s before ideal — instead of at 09:00.
## CronList + CronCreate output
- CronList output adds a `prompt:` row, JSON-encoded so newlines
stay on one line, truncated to ~200 UTF-8 bytes on a char
boundary. The model can recall a task's intent after compaction
and use it as the source for the documented refresh ritual.
- CronCreate normalizes `args.cron` whitespace BEFORE
`parseCronExpression` so `parsed.raw` is single-line. Otherwise
inputs like `"1\n2\n3\n4\n5"` (legal — parser accepts any \s+)
produced a multi-line `humanSchedule:` row via the cronToHuman
raw-fallback branch.
## Misc hardening
- `KIMI_CRON_CLOCK=file:<path>` reads at most 64 bytes via
`openSync` + `readSync` so a stray-large file can't OOM.
- SIGUSR1 handler logs swallowed `tick()` exceptions to stderr when
`KIMI_CRON_DEBUG=1` (matches scheduler's debug pattern); silent in
production.
- Documentation rewrite across cron-list.md / cron-create.md /
cron-delete.md so the documented stale + nextFireAt behaviour
matches the implementation (recurring stale tasks auto-delete after
the final fire; `nextFireAt` is an ISO timestamp; refresh ritual
is "just CronCreate again — the old id is already gone").
Tests cover each of the above. Suite at 2090+ passing across
agent-core, kosong, and the kimi-code app; typecheck clean across
all workspace packages.
* feat(cron): persist scheduled tasks across kimi resume
Add per-id JSON persistence so cron tasks survive a kimi resume of the same session.
Core changes:
- CronManager: addTask / removeTasks mirror mutations to <sessionDir>/cron/<id>.json
- CronManager.loadFromDisk() rehydrates the in-memory store on resume
- CronManager.flushPersist() drains pending writes for graceful shutdown
- SessionCronStore.adopt() inserts persisted tasks with original id and createdAt
- Extract shared createPerIdJsonStore utility from background/persist.ts
- Refactor background/persist.ts to use createPerIdJsonStore (no behavior change)
- New tests: resume.test.ts, persist.test.ts, per-id-json-store.test.ts
- Update cron-create / cron-list / cron-delete docs to reflect session lifetime
* fix(agent-core): fix cron-stop-on-close test import for tsgo compat
* feat(cron): persist lastFiredAt cursor and scope approval rules
- persist lastFiredAt across resume so recurring tasks don't replay
- add one-shot pinned-date guard to reject >1-year-out first fires
- scope CronCreate approvalRule to exact payload (cron, prompt, recurring)
- add resume replay tests and corrupt-cursor fallback test
- stop cron before awaiting background shutdown so due ticks cannot
start a fresh turn while session.close() is mid-flight
- filter cron_job / cron_missed origins from markdown export so the
internal <cron-fire ...> envelope no longer leaks into user-facing
exports