diff --git a/.github/pr-assets/5428-reauth-unknown-flow-get.jpg b/.github/pr-assets/5428-reauth-unknown-flow-get.jpg
new file mode 100644
index 00000000000..c625ed73a21
Binary files /dev/null and b/.github/pr-assets/5428-reauth-unknown-flow-get.jpg differ
diff --git a/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md
index f7f030084a9..a27c412b9ef 100644
--- a/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md
+++ b/devlog/_fin/260904_repo_hygiene_campaign/000_plan.md
@@ -17,15 +17,17 @@ every contributor whose work is carried.
## Classification of local branches
-Every branch was scored on four independent axes rather than by name:
+Every branch was scored on four independent axes rather than by name. Axis 3 is
+shown in its corrected form; the campaign itself ran it without `--no-renames`
+(see the 2026-09-21 correction in 010_method.md):
1. `git merge-base --is-ancestor
origin/dev` — plain ancestry.
2. `git cherry origin/dev
` — patch-equivalence, which catches rebases.
3. Content landing — the files the branch touches
- (`git diff --name-only origin/dev...
`) are compared two-dot against
- `origin/dev` restricted to exactly those paths. Zero remaining difference
- means the branch's content is already on `dev` even though a squash merge
- destroyed its commit identity.
+ (`git diff --no-renames --name-only origin/dev...
`) are compared two-dot
+ against `origin/dev` restricted to exactly those paths. Zero remaining
+ difference means the branch's content is already on `dev` even though a
+ squash merge destroyed its commit identity.
4. Exact reference matching against live GitHub state: open-PR head refs,
worktree-backing refs, and the PR number a scratch branch was cut for.
diff --git a/devlog/_fin/260904_repo_hygiene_campaign/010_method.md b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md
index 773b6db6494..c8e98858bc5 100644
--- a/devlog/_fin/260904_repo_hygiene_campaign/010_method.md
+++ b/devlog/_fin/260904_repo_hygiene_campaign/010_method.md
@@ -7,8 +7,8 @@ A local branch is deletable when at least one holds, and no guard fires.
```
T1 ancestry git merge-base --is-ancestor
origin/dev
T2 patch-equiv git cherry origin/dev
-> no '+' lines
-T3 content paths = git diff --name-only origin/dev...
- git diff --name-only origin/dev
-- -> empty
+T3 content paths = git diff --no-renames --name-only origin/dev...
+ git diff --no-renames --name-only origin/dev
-- -> empty
T4 scratch branch name encodes a PR number whose state is MERGED or CLOSED
AND the name matches the scratch prefix set
AND the number is a WHOLE numeric token of the branch name
@@ -22,6 +22,17 @@ report "unmerged" for work that is fully shipped. T3 asks the only question that
is actually load-bearing — is there any difference left in the files this branch
claims to change.
+Correction, 2026-09-21: the 71 deletions recorded below ran the listing command
+without `--no-renames`. Rename detection must be disabled while collecting that
+path set, and any rerun after this date should use the form shown above.
+Otherwise a rename contributes only its destination: if `dev` independently
+contains the same destination but retains the source, the restricted second
+diff is empty even though the complete tip trees differ. `--no-renames` emits
+both the deleted source and added destination, so the source-side difference
+prevents a false LANDED verdict. No wrongly-LANDED branch has been identified
+from the earlier run; this is a preventive correction for the next sweep, not a
+measured incident.
+
T4 is deliberately narrow. It fires only for throwaway prefixes
(`pr*`, `rb-`, `jrb-`, `mtp/`, `big-`, `cf-`, `ocx-`, `wip/`, `backup/`,
`candidate`, `cursor-`, `midstream`) created by earlier review and rebase runs,
diff --git a/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md
index bf8e5d701c0..3ff033a1c98 100644
--- a/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md
+++ b/devlog/_fin/260904_repo_hygiene_campaign/100_pr_verdicts.md
@@ -1,10 +1,12 @@
# 100 — Per-PR verdicts
Full classification of the 53 pull requests open when the campaign started.
-Method: fetch each PR head, take the files it touches
-(`git diff --name-only origin/dev...`), then compare those exact paths
-two-dot against `origin/dev`. Remaining differences mean the work has not
-landed.
+Method: fetch each PR head, take the files it touches, then compare those exact
+paths two-dot against `origin/dev`. Remaining differences mean the work has not
+landed. The verdicts below were produced with
+`git diff --name-only origin/dev...`; any rerun must use
+`git diff --no-renames --name-only origin/dev...` so a rename cannot hide
+the deleted source side from the path set (2026-09-21; see 010_method.md).
## Closed
diff --git a/gui/src/components/use-main-device-reauth.ts b/gui/src/components/use-main-device-reauth.ts
index 626e004a5c1..44d18b9645f 100644
--- a/gui/src/components/use-main-device-reauth.ts
+++ b/gui/src/components/use-main-device-reauth.ts
@@ -181,6 +181,12 @@ export function useMainDeviceReauth(apiBase: string, onCompleted: () => void) {
const dto = await res.json().catch(() => ({})) as FlowDto;
if (!isCurrent() || flowRef.current !== flowId) return;
if (!res.ok) {
+ if (res.status === 404 && dto.code === "unknown_flow") {
+ stopPolling();
+ flowRef.current = null;
+ setState({ phase: "failed", code: "request_failed" });
+ return;
+ }
// Ownership continues from the Cancel click, including while DELETE
// is unresolved. Never offer a replacement POST during that window,
// and keep the existing poll cadence so a later terminal status remains observable.
diff --git a/gui/tests/main-device-reauth-ownership.test.tsx b/gui/tests/main-device-reauth-ownership.test.tsx
index 917cb5f80d1..e90315560b3 100644
--- a/gui/tests/main-device-reauth-ownership.test.tsx
+++ b/gui/tests/main-device-reauth-ownership.test.tsx
@@ -315,6 +315,17 @@ for (const failure of ["network", "http", "nonterminal"] as const) {
});
}
+test("unknown flow status stops polling after a failed cancellation", async () => {
+ await mount();
+ await beginFlow("A");
+ await invoke(() => hook.cancel());
+ await reply(take("DELETE", "A"), { code: "unavailable" }, 503);
+ await act(async () => { for (const wake of sleepers.splice(0)) wake(); });
+ await reply(take("GET", "A"), { code: "unknown_flow" }, 404);
+ expect(hook.state).toEqual({ phase: "failed", code: "request_failed" });
+ expect(sleepers).toHaveLength(0);
+});
+
test("two successful cancellation replies complete the same flow only once", async () => {
await mount();
await beginFlow("A");
diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts
index 8a1b6df6206..529a9c0c882 100644
--- a/src/adapters/qoder/scaffold-guard.ts
+++ b/src/adapters/qoder/scaffold-guard.ts
@@ -54,11 +54,44 @@ const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length));
* refuse the turn. A stem running to the end of the buffer still counts: more text may be
* arriving, and reading it as prose is the one reading that could release the block body.
*/
-function reminderOpensHere(lowered: string, at: number): boolean {
- const after = lowered[at + REMINDER_OPEN.length];
+function reminderOpensHere(text: string, at: number): boolean {
+ const after = text[at + REMINDER_OPEN.length];
return after === undefined || /[\s/>]/.test(after);
}
+/**
+ * Fold one UTF-16 code unit the way `toLowerCase()` does, when that yields one code unit.
+ *
+ * This keeps every match the lowercased scan used to make. U+212A KELVIN SIGN lowercases to an
+ * ASCII `k`, so `` was treated as tool markup; an ASCII-only fold would release it.
+ * A character whose lowercase form is longer (such as U+0130) is left as-is.
+ */
+function foldCodeUnit(code: number): number {
+ if (code >= 65 && code <= 90) return code + 32;
+ if (code < 128) return code;
+ const lowered = String.fromCharCode(code).toLowerCase();
+ return lowered.length === 1 ? lowered.charCodeAt(0) : code;
+}
+
+/**
+ * Find a lowercase ASCII marker without transforming `text`.
+ *
+ * Marker offsets must remain offsets into the original string. Unicode lowercasing can expand
+ * one code unit into several (for example, `İ` becomes `i` plus a combining dot), so an index
+ * obtained from `text.toLowerCase()` is unsafe to reuse with `text.slice()`. Folding one code
+ * unit at a time keeps the offsets and the matches.
+ */
+function indexOfMarker(text: string, marker: string, from = 0): number {
+ const last = text.length - marker.length;
+ outer: for (let at = Math.max(0, from); at <= last; at++) {
+ for (let offset = 0; offset < marker.length; offset++) {
+ if (foldCodeUnit(text.charCodeAt(at + offset)) !== marker.charCodeAt(offset)) continue outer;
+ }
+ return at;
+ }
+ return -1;
+}
+
/**
* Ceiling on a suppressed block before it is treated as unterminated.
*
@@ -80,9 +113,10 @@ export interface ScaffoldFilterResult {
function heldSuffixLength(text: string): number {
const limit = Math.min(MAX_MARKER_LENGTH - 1, text.length);
for (let length = limit; length > 0; length--) {
- const suffix = text.slice(text.length - length).toLowerCase();
for (const marker of ALL_MARKERS) {
- if (marker.length > length && marker.startsWith(suffix)) return length;
+ if (marker.length > length && indexOfMarker(text, marker.slice(0, length), text.length - length) >= 0) {
+ return length;
+ }
}
}
return 0;
@@ -114,7 +148,6 @@ export class QoderScaffoldFilter {
for (;;) {
if (this.mode === "suppress") {
const scan = this.suppressedTail + buffer;
- const scanned = scan.toLowerCase();
// Unwind nesting rather than ending at the first closer. A reminder containing another
// reminder would otherwise hand the outer block's remaining body — the MCP server list
// in the reported leak — to the client as the model's answer, with a successful
@@ -122,11 +155,11 @@ export class QoderScaffoldFilter {
let cursor = 0;
let close = -1;
for (;;) {
- const nextClose = scanned.indexOf(REMINDER_CLOSE, cursor);
+ const nextClose = indexOfMarker(scan, REMINDER_CLOSE, cursor);
if (nextClose < 0) break;
- let nextOpen = scanned.indexOf(REMINDER_OPEN, cursor);
- while (nextOpen >= 0 && !reminderOpensHere(scanned, nextOpen)) {
- nextOpen = scanned.indexOf(REMINDER_OPEN, nextOpen + 1);
+ let nextOpen = indexOfMarker(scan, REMINDER_OPEN, cursor);
+ while (nextOpen >= 0 && !reminderOpensHere(scan, nextOpen)) {
+ nextOpen = indexOfMarker(scan, REMINDER_OPEN, nextOpen + 1);
}
if (nextOpen >= 0 && nextOpen < nextClose) {
this.suppressDepth += 1;
@@ -159,11 +192,10 @@ export class QoderScaffoldFilter {
let earliest = -1;
let found = "";
- const lowered = buffer.toLowerCase();
for (const marker of ALL_MARKERS) {
- let at = lowered.indexOf(marker);
- while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(lowered, at)) {
- at = lowered.indexOf(marker, at + 1);
+ let at = indexOfMarker(buffer, marker);
+ while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(buffer, at)) {
+ at = indexOfMarker(buffer, marker, at + 1);
}
if (at < 0) continue;
// A closer sitting exactly where an opener starts cannot happen, so ties are impossible.
diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts
index 7ae70edf461..e0ef3191f6d 100644
--- a/src/integrations/raycast-detect.ts
+++ b/src/integrations/raycast-detect.ts
@@ -44,10 +44,19 @@ export interface RaycastDetectDeps {
*/
const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1";
const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active";
+const DEFAULTS_PATH = "/usr/bin/defaults";
+const DEFAULTS_TIMEOUT_MS = 2_000;
-export function realRaycastDetectDeps(): RaycastDetectDeps {
+interface RealRaycastDetectRuntime {
+ platform?: string;
+ spawnSync?: typeof Bun.spawnSync;
+}
+
+export function realRaycastDetectDeps(runtime: RealRaycastDetectRuntime = {}): RaycastDetectDeps {
+ const platform = runtime.platform ?? process.platform;
+ const spawnSync = runtime.spawnSync ?? Bun.spawnSync;
return {
- platform: process.platform,
+ platform,
homedir: homedir(),
env: process.env,
exists: path => {
@@ -59,9 +68,15 @@ export function realRaycastDetectDeps(): RaycastDetectDeps {
},
readDefault: (domain, key) => {
// `defaults` is macOS-only; elsewhere the plan is simply unknown.
- if (process.platform !== "darwin") return null;
+ if (platform !== "darwin") return null;
try {
- const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" });
+ const result = spawnSync([DEFAULTS_PATH, "read", domain, key], {
+ stdout: "pipe",
+ stderr: "pipe",
+ timeout: DEFAULTS_TIMEOUT_MS,
+ });
+ // A timed-out or signal-killed probe reports exitCode === null; that
+ // and any non-zero exit mean the preference was not read.
if (result.exitCode !== 0) return null;
return result.stdout.toString().trim();
} catch {
diff --git a/src/lib/bounded-body.ts b/src/lib/bounded-body.ts
index 0b3769eaacf..effb247fb71 100644
--- a/src/lib/bounded-body.ts
+++ b/src/lib/bounded-body.ts
@@ -15,6 +15,8 @@ export interface BoundedBodyOptions {
* Reader cancellation and lock release still run. Defaults to false.
*/
fatalUtf8?: boolean;
+ /** Report UTF-8 validity without rejecting malformed bodies. */
+ reportUtf8Validity?: boolean;
/**
* Byte ceiling for retained body data. Defaults to BOUNDED_BODY_MAX_BYTES (64 KiB),
* which suits error bodies; callers materializing whole success payloads (e.g. a
@@ -44,6 +46,8 @@ export interface BoundedBodyResult {
oversized: boolean;
/** False means callers should use a status-only fallback, not `text`. */
displaySafe: boolean;
+ /** Present when reportUtf8Validity was requested and the retained body reached EOF. */
+ utf8Valid?: boolean;
}
export interface BoundedBytesOptions {
@@ -238,6 +242,14 @@ function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = fa
}
}
+function decodeUtf8WithValidity(bytes: Uint8Array): { text: string; utf8Valid: boolean } {
+ try {
+ return { text: decodeUtf8([bytes], true), utf8Valid: true };
+ } catch {
+ return { text: decodeUtf8([bytes], false), utf8Valid: false };
+ }
+}
+
/**
* Consume the original response body under strict memory and time bounds.
*
@@ -326,6 +338,24 @@ export async function readBoundedResponseBody(
const { value, done } = outcome as ReadableStreamReadResult;
if (done) {
+ if (options.reportUtf8Validity) {
+ const bytes = retained.subarray(0, retainedBytes);
+ // A fatal decode that returned already proved the bytes valid; still
+ // honour the reporting contract instead of dropping utf8Valid.
+ const decoded = options.fatalUtf8 === true
+ ? { text: decodeUtf8([bytes], true), utf8Valid: true }
+ : decodeUtf8WithValidity(bytes);
+ return {
+ text: decoded.text,
+ truncated: false,
+ timedOut: false,
+ totalTimedOut: false,
+ inactivityTimedOut: false,
+ oversized: false,
+ displaySafe: true,
+ utf8Valid: decoded.utf8Valid,
+ };
+ }
return {
text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true),
truncated: false,
diff --git a/src/server/responses/core-combo-failure.ts b/src/server/responses/core-combo-failure.ts
index c14c3bb680b..e222c2ccaea 100644
--- a/src/server/responses/core-combo-failure.ts
+++ b/src/server/responses/core-combo-failure.ts
@@ -45,27 +45,28 @@ export async function consumeComboFailure(
// a second body read, so this mirrors its normalization: raw 402/429, or a 5xx whose intact,
// display-safe body carries a recognized quota message.
let quotaConfirmedByBody = false;
+ const serverError = response.status >= 500 && response.status < 600;
try {
- const body = await readBoundedResponseBody(response, {
- signal,
- // Match shouldRetryCodexPoolAccountQuota before treating a 5xx body as quota evidence.
- fatalUtf8: response.status >= 500 && response.status < 600,
- });
- usage = usageFromComboFailureText(body.text);
- if (
- response.status >= 500 && response.status < 600
- && body.displaySafe && !body.truncated
- ) {
+ const body = await readBoundedResponseBody(response, { signal, reportUtf8Validity: serverError });
+ // A 5xx body counts as quota or classification evidence only when it decoded as valid
+ // UTF-8, matching shouldRetryCodexPoolAccountQuota. A malformed byte keeps the status-only
+ // fallback, with one exception: a cyber-policy refusal must still stop the combo, so the
+ // replacement-decoded text may carry that verdict and nothing else.
+ const utf8Trusted = !serverError || body.utf8Valid === true;
+ if (utf8Trusted) usage = usageFromComboFailureText(body.text);
+ if (serverError && utf8Trusted && body.displaySafe && !body.truncated) {
const quotaMessage = codexQuotaFailureMessage(body.text);
quotaConfirmedByBody = quotaMessage !== undefined
&& isRateLimitOrQuotaFailureMessage(quotaMessage);
}
- if (body.displaySafe) {
+ if (body.displaySafe && !body.truncated) {
const normalized = normalizeUpstreamErrorText(body.text, fallback);
- classificationText = normalized.safeText;
- upstreamCode = normalized.code;
- upstreamMessage = normalized.message;
- upstreamType = normalized.type;
+ if (utf8Trusted || isCyberPolicyCode(normalized.code) || isCyberPolicyMessage(normalized.safeText)) {
+ classificationText = normalized.safeText;
+ upstreamCode = normalized.code;
+ upstreamMessage = normalized.message;
+ upstreamType = normalized.type;
+ }
}
} catch (error) {
if (signal?.aborted) throw error;
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index 5be71c0bfbc..0b95f0146af 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -420,7 +420,7 @@ single forms, and the shell pattern is the part worth keeping stable:
| Subagents | Featured-roster selection workspace (`gui/src/components/subagents-workspace/`). |
| Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). |
| Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). The catalog browses four tabs — Accounts, Free, Local, Paid — where Local is a catalog-only bucket peeled out of `bucketPresets` after `presetTier` has classified; the workspace `providerTier` stays three-way, so the rail, the free-paid sort and the Free count still treat a local runtime as free. Search sits above the tabs and reaches every tab at once: while a query is live the list renders all four groups with headings and the strip becomes jump chips with counts rather than a tablist, because moving the selected tab would change the row kind under the user (a preset-select button becomes a login row). ArrowDown from the search input focuses the first enabled result action; if none is available, focus stays in the input. The tab strip wraps within narrow modals. Every nonempty note has a full-text button so narrow rows never hide content permanently; the native note dialog closes during teardown and restores focus to its trigger. Provider notes clamp to two lines and open in full in a stacked native `