Skip to content

Commit ae0b00c

Browse files
phixxclaude
andcommitted
fix(autogoal,app): stale-handoff guard — age/provenance + boot auto-expire
Root-causes the SunoSavvy runaway: a handoff written ~3 days earlier auto-presented as a one-click "claim this handoff" in a fresh unrelated session; claiming it resumed an 8-step chain and the auto-loop ran away. CENTER-AUDIT wf_5e11b0c7-efe (Angle A, CONFIRMED): a handoff is intentionally cross-session, but (1) the claim path never consulted createdAt — handoffPanelMode returned "claim" purely on (handoff exists) && (no live goal), and (2) the plugin boot cleanup unlinked terminal state + orphan chains but never touched the handoff file, so it lingered forever and auto-presented as claimable. Operator chose both fixes: Renderer guard (any age) — goal-panel-pure.ts, goal-panel.tsx, en.ts - New pure helpers: handoffAgeMs, isHandoffStale (warn >=1 day or unparseable), formatHandoffAge ("3 days ago"), handoffOriginLabel; MAX_HANDOFF_AGE_MS (7d) / HANDOFF_WARN_AGE_MS (1d). - Claim panel now shows age + origin session + "resumes an N-step chain", amber-warns when stale, and requires a two-step confirm (Claim -> Claim anyway / Cancel). A bare click can never auto-claim; the confirm resets when the handoff stops being claimable. Boot auto-expire backstop (7d) — goal-state.ts, server.ts - Export MAX_HANDOFF_AGE_MS. Boot cleanup quarantines a handoff older than 7 days by renaming to .goal-handoff.json.stale.<ts> (recoverable, mirrors corrupt-file quarantine). Fresh handoffs (legit "resume tomorrow") preserved. Tests - goal-panel-pure.test.ts (+10): age parsing, stale thresholds incl. the 3-day incident case, format buckets, origin label, threshold ordering. - boot-stale-handoff.test.mjs (+3): 8-day quarantined (recoverable), 1-hour preserved, just-under-7-days preserved. Verification: autogoal 1421/1424 (3 unrunnable shell tests), app 196/196 + typecheck clean, desktop tsgo clean. Incident record: docs/audits/2026-06-28-stale-handoff-incident.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0ac138d commit ae0b00c

8 files changed

Lines changed: 419 additions & 9 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Stale-handoff runaway — incident + fix (2026-06-28)
2+
3+
## Incident (SunoSavvy)
4+
Opening a fresh session in `C:/Users/zerop/Development/SunoSavvy` showed a
5+
"claim this handoff" prompt already in the box. The handoff had been written
6+
~Jun 24 and sat **unclaimed for ~3 days**; claiming it (`resumedFromHandoffAt`,
7+
`metadata.setBy: "chain"`) **resumed an 8-step chain** and the auto-loop began
8+
nudging through it. The operator could not stop it.
9+
10+
Emergency response: the SunoSavvy goal state was set `active → cleared` and the
11+
chain file removed (auto-loop only drives `active`). Backed up to
12+
`%TEMP%/sunosavvy-goal-backup-20260627-233705/` (reversible). Confirmed not
13+
re-arming over a 15s window.
14+
15+
## Root cause (CENTER-AUDIT wf_5e11b0c7-efe, Angle A — CONFIRMED)
16+
A handoff is intentionally cross-session, but:
17+
1. **No age gate** on the claim path — `handoffPanelMode` returned `"claim"`
18+
purely on `(handoff exists) && (no live goal)`; `createdAt` was never
19+
consulted (`goal-panel-pure.ts`).
20+
2. **Not in boot cleanup** — the plugin boot block unlinked terminal state +
21+
orphan chains but never touched `.goal-handoff.json` (`server.ts`), so a
22+
handoff lingered forever and auto-presented as a one-click claim.
23+
24+
(Adversarial verification of Angle A and Angles B/C did not complete — the
25+
workflow hit a session token limit. Angle B's orchestration concern — a late
26+
continuation firing at the chain — was independently addressed by the
27+
`stale-suppressed` continuation work shipped in v1.17.9-vc1.)
28+
29+
## Fix (two-part, operator chose "both")
30+
31+
### Renderer guard (any age) — `goal-panel-pure.ts`, `goal-panel.tsx`, `en.ts`
32+
- New pure helpers: `handoffAgeMs`, `isHandoffStale` (warn ≥ 1 day or
33+
unparseable), `formatHandoffAge` ("3 days ago"), `handoffOriginLabel`
34+
(short origin session id), plus `MAX_HANDOFF_AGE_MS` (7d) /
35+
`HANDOFF_WARN_AGE_MS` (1d).
36+
- The claim panel now shows **age + origin session + "resumes an N-step chain"**,
37+
amber-warns when stale, and requires a **two-step confirm** ("Claim" →
38+
"Claim anyway"/"Cancel"). A bare click can never auto-claim. The confirm
39+
resets if the handoff stops being claimable.
40+
41+
### Boot auto-expire backstop (7 days) — `goal-state.ts`, `server.ts`
42+
- Exported `MAX_HANDOFF_AGE_MS`. Boot cleanup quarantines a handoff older than
43+
7 days by renaming to `.goal-handoff.json.stale.<ts>` (recoverable, mirrors
44+
corrupt-file quarantine). A fresh handoff (legit "resume tomorrow") is
45+
preserved.
46+
47+
## Tests
48+
- `goal-panel-pure.test.ts` (+10): age parsing, stale thresholds (incl. the
49+
3-day incident case), format buckets, origin label, threshold ordering.
50+
- `boot-stale-handoff.test.mjs` (+3): 8-day handoff quarantined (recoverable),
51+
1-hour preserved, just-under-7-days preserved.
52+
53+
Verification: autogoal 1421/1424 (3 unrunnable shell tests), app 196/196 +
54+
typecheck clean, desktop tsgo clean.

packages/app/src/i18n/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,11 @@ export const dict = {
828828
"session.goal.handoff.pending": "Pending handoff",
829829
"session.goal.handoff.liveRunPending": "Current run active. Claim becomes available when the live goal is cleared.",
830830
"session.goal.handoff.corrupt": "A handoff file exists but could not be read. Run doctor or remove the corrupt handoff before claiming.",
831+
"session.goal.handoff.age": "Created {{age}}",
832+
"session.goal.handoff.fromSession": "from {{session}}",
833+
"session.goal.handoff.resumesChain": "Resumes a {{total}}-step chain.",
834+
"session.goal.handoff.staleWarn": "This handoff has been waiting a while. Confirm before resuming.",
835+
"session.goal.handoff.claimConfirm": "Claim anyway",
831836
"session.goal.progress": "Progress",
832837
"session.goal.runControls": "Run Controls",
833838
"session.goal.constraints": "Constraints",

packages/app/src/pages/session/goal-panel-pure.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,3 +839,68 @@ describe("C3: createOrderedChainRefresh backpressure option", () => {
839839
refresh.dispose();
840840
});
841841
});
842+
843+
import {
844+
handoffAgeMs,
845+
isHandoffStale,
846+
formatHandoffAge,
847+
handoffOriginLabel,
848+
MAX_HANDOFF_AGE_MS,
849+
HANDOFF_WARN_AGE_MS,
850+
} from "./goal-panel-pure";
851+
852+
describe("handoff staleness guard (SunoSavvy incident)", () => {
853+
const NOW = 1_782_621_146_968; // fixed reference
854+
const iso = (ms: number) => new Date(ms).toISOString();
855+
856+
test("handoffAgeMs: parses ISO and returns positive age", () => {
857+
expect(handoffAgeMs(iso(NOW - 3 * 86_400_000), NOW)).toBe(3 * 86_400_000);
858+
});
859+
860+
test("handoffAgeMs: unparseable createdAt → null", () => {
861+
expect(handoffAgeMs("not-a-date", NOW)).toBeNull();
862+
expect(handoffAgeMs("", NOW)).toBeNull();
863+
});
864+
865+
test("handoffAgeMs: future timestamp (clock skew) clamps to 0", () => {
866+
expect(handoffAgeMs(iso(NOW + 60_000), NOW)).toBe(0);
867+
});
868+
869+
test("isHandoffStale: 3-day-old handoff is stale (the incident case)", () => {
870+
expect(isHandoffStale(iso(NOW - 3 * 86_400_000), NOW)).toBe(true);
871+
});
872+
873+
test("isHandoffStale: a 10-minute-old handoff is NOT stale", () => {
874+
expect(isHandoffStale(iso(NOW - 10 * 60_000), NOW)).toBe(false);
875+
});
876+
877+
test("isHandoffStale: unparseable age is treated as stale (suspicious)", () => {
878+
expect(isHandoffStale("garbage", NOW)).toBe(true);
879+
});
880+
881+
test("isHandoffStale: exactly at the warn threshold is stale", () => {
882+
expect(isHandoffStale(iso(NOW - HANDOFF_WARN_AGE_MS), NOW)).toBe(true);
883+
expect(isHandoffStale(iso(NOW - (HANDOFF_WARN_AGE_MS - 1)), NOW)).toBe(false);
884+
});
885+
886+
test("formatHandoffAge: human-readable buckets", () => {
887+
expect(formatHandoffAge(iso(NOW - 30_000), NOW)).toBe("just now");
888+
expect(formatHandoffAge(iso(NOW - 5 * 60_000), NOW)).toBe("5m ago");
889+
expect(formatHandoffAge(iso(NOW - 3 * 3_600_000), NOW)).toBe("3h ago");
890+
expect(formatHandoffAge(iso(NOW - 1 * 86_400_000), NOW)).toBe("1 day ago");
891+
expect(formatHandoffAge(iso(NOW - 3 * 86_400_000), NOW)).toBe("3 days ago");
892+
expect(formatHandoffAge("nope", NOW)).toBe("unknown age");
893+
});
894+
895+
test("handoffOriginLabel: shortens a long session id, null when absent", () => {
896+
expect(handoffOriginLabel({ metadata: { sessionId: "ses_107bcba1dffen0VQjFx4El1ryQ" } } as never)).toBe("ses_107bcba1d…");
897+
expect(handoffOriginLabel({ metadata: {} } as never)).toBeNull();
898+
expect(handoffOriginLabel(null)).toBeNull();
899+
});
900+
901+
test("thresholds: boot expiry is generous (7d) vs warn (1d)", () => {
902+
expect(MAX_HANDOFF_AGE_MS).toBe(7 * 24 * 60 * 60 * 1000);
903+
expect(HANDOFF_WARN_AGE_MS).toBe(24 * 60 * 60 * 1000);
904+
expect(MAX_HANDOFF_AGE_MS).toBeGreaterThan(HANDOFF_WARN_AGE_MS);
905+
});
906+
});

packages/app/src/pages/session/goal-panel-pure.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,6 +1372,57 @@ export function handoffPanelMode(liveGoal: GoalState | null, store: GoalHandoffS
13721372
return liveGoal ? "pending" : "claim"
13731373
}
13741374

1375+
// ── Handoff staleness (CENTER-AUDIT 2026-06-27, SunoSavvy incident) ──────────
1376+
// A handoff is intentionally cross-session, but one left unclaimed for a long
1377+
// time and then auto-presented as a one-click "Claim" in an unrelated new
1378+
// session is the stale-handoff surprise that resumed an 8-step chain. The
1379+
// renderer surfaces the age + origin and requires an explicit confirm at any
1380+
// age; the plugin boot cleanup quarantines a handoff older than MAX.
1381+
/** Boot-expiry threshold: a handoff older than this is quarantined at startup. */
1382+
export const MAX_HANDOFF_AGE_MS = 7 * 24 * 60 * 60 * 1000
1383+
/** Soft threshold: older than this, the claim panel shows an amber warning. */
1384+
export const HANDOFF_WARN_AGE_MS = 24 * 60 * 60 * 1000
1385+
1386+
/** Age of a handoff in ms, or null when createdAt is unparseable. Negative
1387+
* (clock skew / future timestamp) clamps to 0. */
1388+
export function handoffAgeMs(createdAt: string, now: number): number | null {
1389+
const created = Date.parse(createdAt)
1390+
if (!Number.isFinite(created)) return null
1391+
return Math.max(0, now - created)
1392+
}
1393+
1394+
/** True when the handoff is old enough to warrant a warning before claiming
1395+
* (or its createdAt is unparseable — treat unknown age as suspicious). */
1396+
export function isHandoffStale(createdAt: string, now: number, threshold = HANDOFF_WARN_AGE_MS): boolean {
1397+
const age = handoffAgeMs(createdAt, now)
1398+
if (age === null) return true
1399+
return age >= threshold
1400+
}
1401+
1402+
/** Human-readable handoff age, e.g. "just now", "5m ago", "3h ago",
1403+
* "3 days ago". Returns "unknown age" when createdAt is unparseable. */
1404+
export function formatHandoffAge(createdAt: string, now: number): string {
1405+
const age = handoffAgeMs(createdAt, now)
1406+
if (age === null) return "unknown age"
1407+
if (age < 60_000) return "just now"
1408+
const mins = Math.floor(age / 60_000)
1409+
if (mins < 60) return `${mins}m ago`
1410+
const hours = Math.floor(age / 3_600_000)
1411+
if (hours < 24) return `${hours}h ago`
1412+
const days = Math.floor(age / 86_400_000)
1413+
return days === 1 ? "1 day ago" : `${days} days ago`
1414+
}
1415+
1416+
/** Short, display-safe origin-session label for handoff provenance, e.g.
1417+
* "ses_107bcba1…". Returns null when there's no session id. */
1418+
export function handoffOriginLabel(state: GoalState | null | undefined): string | null {
1419+
const sid = (state as (GoalState & { metadata?: { sessionId?: unknown } }) | null | undefined)?.metadata?.sessionId
1420+
if (typeof sid !== "string") return null
1421+
const clean = cleanText(sid).trim()
1422+
if (!clean) return null
1423+
return clean.length > 14 ? `${clean.slice(0, 13)}…` : clean
1424+
}
1425+
13751426
export type SteerDraftDisposition = "retain" | "clear"
13761427

13771428
export function steerDraftDisposition(input: {

packages/app/src/pages/session/goal-panel.tsx

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ import {
8383
GOAL_TEMPLATE_TONES,
8484
actionCategoryShortLabel,
8585
handoffPanelMode,
86+
formatHandoffAge,
87+
isHandoffStale,
88+
handoffOriginLabel,
8689
goalInterruptionWarningKey,
8790
isGoalPinnedModel,
8891
steerDraftDisposition,
@@ -1541,6 +1544,9 @@ export function GoalPanel(props: { goal: { store: GoalStore; refresh: () => Prom
15411544
const [now, setNow] = createSignal(Date.now())
15421545
const [controlError, setControlError] = createSignal<string | null>(null)
15431546
const [confirmingClear, setConfirmingClear] = createSignal(false)
1547+
// Stale-handoff guard (SunoSavvy incident): claiming a handoff is a two-step
1548+
// confirm so a stale handoff cannot silently auto-claim into a new session.
1549+
const [confirmingClaim, setConfirmingClaim] = createSignal(false)
15441550
const [newCommand, setNewCommand] = createSignal("")
15451551
const [steerOpen, setSteerOpen] = createSignal(false)
15461552
const [steerText, setSteerText] = createSignal("")
@@ -2633,6 +2639,12 @@ export function GoalPanel(props: { goal: { store: GoalStore; refresh: () => Prom
26332639
// null and the run is already in the history timeline.
26342640
const liveGoal = createMemo(() => liveGoalOf(state()))
26352641
const pendingHandoffMode = createMemo(() => handoffPanelMode(liveGoal(), handoff()))
2642+
// Reset the claim confirm whenever the handoff is no longer claimable
2643+
// (claimed elsewhere, cleared, or a live goal started) so the confirm
2644+
// never sticks across a state change.
2645+
createEffect(() => {
2646+
if (pendingHandoffMode() !== "claim") setConfirmingClaim(false)
2647+
})
26362648
const terminalGoal = createMemo(() => terminalGoalOf(state()))
26372649
const runnableChainSteps = () => {
26382650
if (liveGoal()) return []
@@ -4898,12 +4910,40 @@ export function GoalPanel(props: { goal: { store: GoalStore; refresh: () => Prom
48984910
<div class="text-11-regular font-bold uppercase tracking-[0.08em] text-indigo-50">
48994911
{language.t("session.goal.handoff.pending")}
49004912
</div>
4913+
{/* Provenance + age so a stale/foreign handoff is never
4914+
mistaken for a fresh one. (SunoSavvy incident.) */}
4915+
<div
4916+
data-component="goal-handoff-provenance"
4917+
class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] leading-4"
4918+
classList={{
4919+
"text-amber-200/90": isHandoffStale(pending().createdAt, now()),
4920+
"text-indigo-100/60": !isHandoffStale(pending().createdAt, now()),
4921+
}}
4922+
>
4923+
<span class="font-semibold tabular-nums">
4924+
{isHandoffStale(pending().createdAt, now()) ? "⚠ " : ""}
4925+
{language.t("session.goal.handoff.age", { age: formatHandoffAge(pending().createdAt, now()) })}
4926+
</span>
4927+
<Show when={handoffOriginLabel(pending().state)}>
4928+
{(origin) => (
4929+
<span class="truncate">{language.t("session.goal.handoff.fromSession", { session: origin() })}</span>
4930+
)}
4931+
</Show>
4932+
<Show when={Number(pending().state.metadata?.chainTotal ?? 0) > 1}>
4933+
<span>{language.t("session.goal.handoff.resumesChain", { total: String(Number(pending().state.metadata?.chainTotal ?? 0)) })}</span>
4934+
</Show>
4935+
</div>
49014936
<div class="mt-1 truncate text-12-regular text-indigo-100/78">
49024937
{cleanText(pending().state.condition)}
49034938
</div>
49044939
<Show when={pending().note}>
49054940
{(note) => <div class="mt-1 truncate text-11-regular text-indigo-100/62">{note()}</div>}
49064941
</Show>
4942+
<Show when={pendingHandoffMode() === "claim" && confirmingClaim() && isHandoffStale(pending().createdAt, now())}>
4943+
<div class="mt-1 text-[10px] leading-4 text-amber-200/80">
4944+
{language.t("session.goal.handoff.staleWarn")}
4945+
</div>
4946+
</Show>
49074947
</div>
49084948
<Show
49094949
when={pendingHandoffMode() === "claim"}
@@ -4913,14 +4953,39 @@ export function GoalPanel(props: { goal: { store: GoalStore; refresh: () => Prom
49134953
</div>
49144954
}
49154955
>
4916-
<ActionButton
4917-
label={language.t("session.goal.action.claim")}
4918-
variant="primary"
4919-
busy={busy() === "claim"}
4920-
disabled={busy() !== null || !props.sessionID}
4921-
class="h-8 shrink-0 px-3"
4922-
onClick={() => void claimGoalHandoff()}
4923-
/>
4956+
{/* Two-step confirm: a bare click never claims; the
4957+
operator must confirm after seeing age + origin. */}
4958+
<Show
4959+
when={confirmingClaim()}
4960+
fallback={
4961+
<ActionButton
4962+
label={language.t("session.goal.action.claim")}
4963+
variant="primary"
4964+
disabled={busy() !== null || !props.sessionID}
4965+
class="h-8 shrink-0 px-3"
4966+
onClick={() => setConfirmingClaim(true)}
4967+
/>
4968+
}
4969+
>
4970+
<div class="flex shrink-0 items-center gap-1">
4971+
<ActionButton
4972+
label={language.t("session.goal.handoff.claimConfirm")}
4973+
variant="primary"
4974+
tone="danger"
4975+
busy={busy() === "claim"}
4976+
disabled={busy() !== null || !props.sessionID}
4977+
class="h-8 shrink-0 px-3"
4978+
onClick={() => void claimGoalHandoff()}
4979+
/>
4980+
<ActionButton
4981+
label={language.t("session.goal.action.cancel")}
4982+
variant="ghost"
4983+
disabled={busy() !== null}
4984+
class="h-8 shrink-0 px-2.5"
4985+
onClick={() => setConfirmingClaim(false)}
4986+
/>
4987+
</div>
4988+
</Show>
49244989
</Show>
49254990
</div>
49264991
</div>

packages/autogoal/src/goal-state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,13 @@ export function clearSteering(directory: string, now: number = Date.now()): { ok
16181618

16191619
export const HANDOFF_FILE = ".opencode/.goal-handoff.json";
16201620

1621+
/** Boot-expiry threshold for an unclaimed handoff. A handoff is intentionally
1622+
* cross-session, but one left unclaimed this long is abandoned; the plugin
1623+
* quarantines it at boot so it can't auto-present as claimable in an unrelated
1624+
* new session. (CENTER-AUDIT 2026-06-27, SunoSavvy stale-handoff incident.)
1625+
* Mirrors the renderer's MAX_HANDOFF_AGE_MS in goal-panel-pure.ts. */
1626+
export const MAX_HANDOFF_AGE_MS = 7 * 24 * 60 * 60 * 1000;
1627+
16211628
export interface HandoffPayload {
16221629
/** ISO-8601 timestamp of when the handoff was created. */
16231630
createdAt: string;

packages/autogoal/src/server.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { tool } from "./plugin-api.js";
2020
import { exec } from "node:child_process";
2121
import { promisify } from "node:util";
2222
import { resolve, relative, isAbsolute } from "node:path";
23-
import { readFileSync, statSync, unlinkSync, existsSync } from "node:fs";
23+
import { readFileSync, statSync, unlinkSync, existsSync, renameSync } from "node:fs";
2424
import {
2525
readGoalStateResult,
2626
listCorruptArtifacts,
@@ -43,6 +43,9 @@ import {
4343
COMPLETE_RE,
4444
BLOCKED_RE,
4545
goalStatePath,
46+
handoffPath,
47+
readHandoffResult,
48+
MAX_HANDOFF_AGE_MS,
4649
type GoalState,
4750
type GoalEvaluation,
4851
type GoalStatus,
@@ -1197,6 +1200,44 @@ export const server: Plugin = async ({ client, directory }) => {
11971200
log("debug", "boot clear: chain check failed (non-fatal)", { error: String(err) });
11981201
}
11991202

1203+
// Handoff file: quarantine an unclaimed handoff older than MAX_HANDOFF_AGE_MS.
1204+
// A handoff is intentionally cross-session (hand a goal to a future session),
1205+
// so it is NOT removed merely for being from another session. But one left
1206+
// unclaimed for >7 days is abandoned; presenting it as a one-click "Claim" in
1207+
// an unrelated new session is the stale-handoff surprise that resumed an
1208+
// 8-step chain (SunoSavvy 2026-06-27). The renderer also age-gates the claim
1209+
// (formatHandoffAge + two-step confirm); this backstop stops a truly-
1210+
// abandoned handoff from lingering as claimable across app restarts. Quarantine
1211+
// (rename to .stale.<ts>) rather than delete, mirroring corrupt-file handling,
1212+
// so it is recoverable.
1213+
try {
1214+
const hp = handoffPath(directory);
1215+
if (existsSync(hp)) {
1216+
const h = readHandoffResult(directory);
1217+
if (h.kind === "ok") {
1218+
const createdMs = Date.parse(h.value.createdAt);
1219+
const ageMs = Number.isFinite(createdMs) ? Date.now() - createdMs : Number.POSITIVE_INFINITY;
1220+
// Quarantine when older than the threshold OR when createdAt is
1221+
// unparseable (unknown age is treated as suspicious, same as the
1222+
// renderer's isHandoffStale).
1223+
if (ageMs > MAX_HANDOFF_AGE_MS) {
1224+
try {
1225+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1226+
renameSync(hp, `${hp}.stale.${stamp}`);
1227+
log("info", "boot clear: quarantined stale handoff", {
1228+
ageDays: Number.isFinite(ageMs) ? Math.floor(ageMs / 86_400_000) : null,
1229+
createdAt: h.value.createdAt,
1230+
});
1231+
} catch (err) {
1232+
log("debug", "boot clear: handoff quarantine failed (non-fatal)", { error: String(err) });
1233+
}
1234+
}
1235+
}
1236+
}
1237+
} catch (err) {
1238+
log("debug", "boot clear: handoff check failed (non-fatal)", { error: String(err) });
1239+
}
1240+
12001241
// ── User-facing notifications, frontend-agnostic ─────────────────────────
12011242
// `client.tui.showToast` only renders in the terminal TUI; on the Desktop
12021243
// (Electron) app it is a no-op. The conversation is the one shared surface, so

0 commit comments

Comments
 (0)