v0.9.2 — OAuth refresh ordering, Pi system prompt persistence, cross-machine spawn guard, i18n key restore, source_test/streaming/browser hotfixes
Bug Fixes
-
OAuth tokens silently refresh before agent build — On cold sessions,
SessionManager.sendMessagerangetOrCreateAgent→buildServersFromSources→refreshOAuthTokensIfNeededin that order. The first build saw stale tokens, emittedAUTH_REQUIRED, and the wrapper calledmarkSourceNeedsReauth— flippingisAuthenticated=falseon disk. The user saw a brief "needs auth" UI flicker before the late refresh restored state. Three coordinated changes: (1) refresh now runs beforegetOrCreateAgentso its internal cold-session build sees fresh tokens, the oldrefreshOAuthTokensIfNeeded(refresh + conditional rebuild) is gone, and a single post-refresh build is the only build per send; (2)buildServersFromSourcesreclassifiesAUTH_REQUIRED→TOKEN_EXPIREDwhen the credential is merely expired-but-refreshable and skipsmarkSourceNeedsReauthin that case (prevents flicker if refresh is skipped, e.g. during cooldown); (3)TokenRefreshManager.ensureFreshTokenfailure branches now mirrormarkSourceNeedsReauth's disk write tosource.configin memory, soisSourceUsable()returns false and the failed source is excluded fromintendedSlugsby the post-refresh build. Closes #710. (347820ab) -
Pi backend silently dropped the Craft system prompt — Pi SDK 0.72.1's
session.prompt()wipesagent.state.systemPromptback to_baseSystemPrompton every turn (agent-session.js~L796), sopi-agent-server's direct assignment was silently dropped — taking preferences/notes,<session_state>,<sources>, working directory, and all other Craft-built system-prompt content with it. The Anthropic backend was unaffected because it uses the SDK'sappend:option, which the Claude SDK appends verbatim. NewapplySystemPromptOverride()helper stamps all three SDK private fields (state.systemPrompt,_baseSystemPrompt,_rebuildSystemPrompt) so the prompt survives both per-turn resets and tool-change rebuilds. Pattern matches OpenClaw'sapplySystemPromptOverrideToSession— same SDK, same constraint, validated workaround until upstream exposes a public API. Wired into both call sites that previously didstate.systemPrompt = …: the per-turnprompthandler and the ephemeralqueryLlmsession. Regression test asserts all three fields are stamped (catches the original single-field regression and any future drop of one of the writes). Closes #648. (13f6e63e) -
SDK spawn guard against stale branch cwd from cross-machine imports — The SDK's
Claude Code native binary not found at …error is a misleading wrapper aroundspawn ENOENT, which fires whenever the subprocesscwdis also missing — not just when the binary is missing. Cross-machine session imports preservebranchInfo.sdkCwdfrom the source machine, so a Send to Workspace recipient hits this error on first chat even though the bundle is fine. A pre-spawn guard now catches stalebranchFromSdkCwdinchatImpl()and routes through the same branch-fallback recovery the post-failure path already uses (parent summary injection preserved). A newonBranchForkInvalidatedcallback persists all four fork fields atomically — fixing a pre-existing bug whereonSdkSessionIdClearedonly persistedsdkSessionId, leaving stale branch fields on disk to reload on next launch. ENOENT classification was lifted out of theif (isProcessError)gate (it never fired for the SDK wrapper, which is aReferenceError, not a process-exit error); now it disambiguatesbinaryvscwdvsunknowncauses, surfaces typedsdk_binary_missing/sdk_cwd_missingerrors after retry exhaustion, and keeps the 2-second auto-retry for the auto-update bundle-swap window. Helpers extracted toagent/spawn-helpers.tsfor unit testing (34 new tests; existing branching suite extended with v2 callback coverage). (12166f4a,e40a3801) -
source_testlastTestedAtnow persists — UI no longer always shows "Never" — Thesource_testhandler wrote an ISO string (new Date().toISOString()); the Zod validator invalidators.tsenforcesz.number().int().min(0), so the string was silently stripped before persist. A second hidden mismatch compounded it:session-tools-core/src/types.tsdeclaredlastTestedAt?: stringwhile the canonical shared type already usednumber. Fixed by switching the handler toDate.now()and aligning the local type tonumber. Storage, validator, and UI (formatRelativeTime) already expected a millisecond timestamp — no other files needed changing. Closes #708. (d4427cac) -
Assistant response content no longer disappears after completion —
handleTextCompleteunconditionally overwrote the streaming-accumulated message content withevent.textfrom the SDK completion event. Ifevent.textarrived empty (SDK race condition or intermediate event without text), the message bubble went blank. Now destructuresstreamingfrom state and applies a fallback chain in both the update-existing and create-new message paths:event.text || streaming?.content || existingMsg?.content || ''. Closes #709. (d4427cac) -
Browser toggle now fully disables system prompt section and prerequisite rule — Three independent sub-problems gated by the same
getBrowserToolEnabled()toggle that was already wired tosession-scoped-tools.tsbut nowhere else. (a) System prompt:system.tsunconditionally injected the## Browser Toolssection and its "calls are blocked until you read browser-tools.md" warning regardless of the setting — now gated. (b) Prerequisite matcher too broad:prerequisite-manager.tsusedisBrowserToolNameOrAlias()which matched any tool namedbrowser_tool, including external MCP tools likemcp__playwright__browser_tool— narrowed to session-scoped canonical names only ('browser_tool'and'mcp__session__browser_tool'). (c) Rule always registered: the prerequisite rule was registered even when the feature was disabled, causing external browser tools to be incorrectly blocked —getBrowserToolEnabled()check now makes the rule a no-op when the toggle is off. Closes #711. (d4427cac)
Improvements
- Spawn-guard defensive cleanup simplified per SOLID/KISS review — Rolled back over-engineering identified after the spawn-ENOENT fix while preserving the load-bearing bits (pre-spawn guard, atomic
onBranchForkInvalidated, working ENOENT classification,.app-bundle-aware regex). InlinedpickFirstExistingDirectoryandprobeEnoentPaths; deletedclassifyEnoentCause— the 4-line directory loop and 2-line probe didn't earn their abstraction at single call sites. Collapsed thebinary|cwd|unknowntrichotomy in the catch block to three plain steps: stale-cwd-with-branch → recovery, first attempt → 2 s retry, retry exhausted → typed error pointed at the more likely cause (removes 5 separateenoentCausebranches). Dropped the 5-valuereasonunion onrecoverFromStaleBranchForkto a singleisPreSpawnboolean — status text was the only differentiator. PromotedlastResolvedCwd/lastResolvedBinaryPathfrom instance fields tochatImpl-scoped locals (visible to the inner catch via closure) — avoids stale-state risk acrosschat()invocations.ErrorCodecentralized in@craft-agent/core/typesvia re-export fromerrors.ts. Net −206 lines, no behavior change; 608 agent tests + 102 server-core tests pass. (e40a3801)
Breaking Changes
- None. All fixes are backward-compatible.
Notes
- The OAuth refresh fix changes the order of operations on every send-message — refresh now runs once before any server build instead of after a stale build. If you operate API/MCP sources with custom OAuth flows, the visible behavior change is fewer disk writes per refresh cycle and no transient
needs_authflicker on cold sessions. No config or migration is required. - Cross-machine "Send to Workspace" recipients who previously hit Claude Code native binary not found at … on first chat in an imported session will now succeed silently — the guard reroutes through the existing branch-fallback recovery and persists clean fork metadata for the next launch.