feat(manager): dashboard UI + batch testing + launcher - #1
Closed
randomix777 wants to merge 68 commits into
Closed
Conversation
…quivalence (lidge-jun#2944) lidge-jun#2943 fixed the nested read; this covers the cases its tests do not, and adds the comment explaining why the precedence is shaped the way it is. The important one is flat-versus-nested disagreement. I had implemented this as deny-wins across both levels in a competing patch, which would have flipped a provider reporting flat vision:true with nested supports.vision:false from image-capable to text-only -- changing behaviour that predates Copilot support, in a parser shared by every provider, from a change whose whole purpose was to stop models being wrongly marked text-only. lidge-jun#2943 resolves by specificity instead: a flat boolean is authoritative when present, nested is consulted only otherwise. Reintroducing deny-wins turns the new test red. An independent review of my withdrawn head raised a second question: the nested read is not scoped to github-copilot, and a nested denial beats a loose "vision" string in a capability array. Probing the merged code shows the nested and flat denials behave identically there, including the contradictory hint pair (inputModalities: ["text"] alongside capabilities: ["vision"]) that flat false has always produced. That equivalence is the property worth pinning: the nested field must mean exactly what the field it stands in for means, or the unscoped read becomes a subtle divergence. Reconciling a boolean denial with a capability list is a separate question and is not answered under a Copilot ticket. Also covered: the reporter's full payload including the limits.vision sibling that holds an image count and would fool anything searching capabilities for a vision-ish key; a non-record supports container falling through so a features: ["vision"] signal still decides; and explicit item input_modalities still outranking a nested claim. Devlog corrected on two overclaims the review was right about: "deeper than any other provider" is only "deeper than the flat form, and no checked-in fixture uses it", and parsed upstream capability is the best per-model evidence rather than authoritative when two forms disagree. No production behaviour change -- the resolution is lidge-jun#2943's implementation.
…ktracking (lidge-jun#2945) The previous classifier placed two adjacent unbounded whitespace runs over the same span, so a long whitespace run followed by one non-matching character forced the engine to retry prefixes. Replaced with a single forward scan. Every loop advances an index monotonically, the newline searches cover disjoint forward spans, and the token checks are fixed-length, so no path retries a prefix. Two boundaries are load-bearing and both were divergences in an earlier attempt at this rewrite: whitespace after Output: may precede the marker, so an indented <empty> still classifies; and only whitespace may follow it, so a duplicate <empty> still does not. That second one matters most -- classifying it would replace a real payload with the failed-wrapper guidance, turning a normalization into data loss. One intended behaviour change: CRLF blank separators now count. The old pattern matched only \n, so a Windows-produced failed wrapper never classified and fell through to the empty-SUCCESS message, telling the model nothing had gone wrong when the cell had failed. Differential comparison over 63 shapes locally and an independent 662-shape pairwise corpus found no disagreement outside that CRLF blank-separator class. Diagnosis and the linear-scan approach are @luvs01's from lidge-jun#2938; that PR could not land as written because of the six divergences, which I posted there with the exact inputs. The bounded-work test measures process.cpuUsage() rather than elapsed wall time: performance.now() counts OS descheduling, VM pauses and GC, so a loaded CI runner can blow a wall-clock budget while the code under test did nothing wrong. Reverting to the previous classifier spends 1230ms CPU against a 250ms bound. Four mutations proven red at the intended test each: whitespace restricted to CR/LF, accepting a second marker, removing CRLF handling, and reverting the classifier.
…ombstone (lidge-jun#2946) Closes an over-claim in lidge-jun#2934's merge record. Its mutation table said removing the `alias.deletedAt != null` check in commitRefreshedCodexCredentialWithAliases turns the resurrection test red. It does not: tombstoneCodexAccount drops the credential, so the separate `!alias.credential` guard already skips that record and the assertion passes with `deletedAt` deleted. The two guards overlap on the only fixture that exercised them, so neither was independently proven. A tombstone that still carries a credential is the only shape that reaches the deletedAt check, and it is reachable: a store written by an older build, or a tombstone raced by a concurrent save. `tokenful tombstone is treated as absent` already pins that shape for the read path, so this uses the same construction for the propagation path. Every other eligibility field matches the owner in this fixture -- same fingerprint, same access token, same expiry, same chatgptAccountId -- so deletedAt is the only thing that can skip it. Removing that check now turns this test red while the other 41 stay green. No production change.
…un#2952) The shipped-asset check treated every `package.json` `files` entry as a possible directory prefix. `assets/banner.png` therefore vouched for `assets/banner.png/missing.gif`, and `LICENSE` for `LICENSE/missing.png`. The intent was right: a directory entry does ship everything beneath it, and the existing comment correctly rejects deciding that by looking for a dot in the name. But prefix matching alone cannot tell the two cases apart either. Ask the filesystem which entries are directories, and let only those act as prefixes. The check is a guard against broken images on the npm package page, so a false negative here is exactly the failure it exists to catch.
A home-rooted lock can couple separate machines while PID liveness remains host-local, and inaccessible homes fail before discovery with raw filesystem errors.\n\nResolve a validated user runtime from XDG or a private UID temp namespace, include a host discriminator, and surface actionable failures. Cover cross-user, cross-host, Windows, fallback, unsafe-root, and path-containment cases.
…n wrap (lidge-jun#2958) * fix(gui): give the provider toggle a real flex basis so the header can wrap The Models provider header collapsed between roughly 1040 and 1380px: the provider name measured 0.0px and painted its glyphs across the active count, and the alias chip broke into a six-line blob. Both were one cause. The toggle's inline `flex: 1` resolves to `flex: 1 1 0%`, and a flex item with a zero base size never reports a content requirement, so the header's existing `flex-wrap: wrap` never learned the toggle needed room and handed it the 31px the actions cluster left over. At 1100 the actions took 422.9 of 488px. Two properties are needed and they pull against each other. Visibility comes from `flex: 1 1 auto`, so the content enters the header's wrap decision. Boundedness comes from removing every child's automatic min-content floor, because a flex child stops shrinking at its own `min-width: auto` and the sum of those floors can still exceed the card. The child rule is quantified rather than enumerated. Four earlier drafts bounded the row by naming the children that could overflow it - name, then alias chip, then the count and badge - and each revision found another one. The `svg` exemption is the inverse failure: the universal rule also matched the chevron, whose inline `width: 14` is not a flex floor, and it rendered 2.5px wide while the containment check still reported success. Text children abbreviate; icons have nothing to truncate. Measured in a real browser at dpr 2: 20/20 cells clean across ko/ru/fr/en/de x 1440/1280/1100/1024, red on the pre-fix stylesheet (ko/1100 three bad rows, ko/1280 four). Containment holds at -2 on five stress cases including every child forced to 64 characters, with the chevron at 14px. Screenshots and the pixel readback that confirms the collapse are in the devlog unit. Also lifts the effective-declaration CSS readers out of viewport-scroll-caps.test.ts, where they were file-local, into gui/tests/helpers/css-declarations.ts so this test can use them without a third copy. * fix(gui): address review findings on the provider-header record Three CodeRabbit findings, all correct: - The new test names cited `lidge-jun#2916`, a PR number guessed before this branch had one. The PR is lidge-jun#2958. - `010` still described the effective-declaration reader as unimportable and left the export-versus-move decision open. B resolved it by moving all four helpers into `gui/tests/helpers/css-declarations.ts` and rewriting the original test to import them, so the doc now records that outcome and lists the module in the diff scope. - `020`'s rendered CDP check asked whether each `button.switch` carries visible text or a `title`, which contradicts the wrapper rule its own item 3 states: a `showLabel` switch puts its text in a sibling inside `.switch-labeled` and carries no `title`. As written the check would have failed exactly the controls that phase fixes, so it now applies the wrapper-aware condition. No behavior change; documentation and test names only.
Cloud Code Assist rejects Gemini 3.7 Flash system instructions containing Claude Code's standalone SDK identity paragraph, but reports the policy rejection as a quota 429.\n\nFilter only the exact paragraph at the CCA Gemini 3.7 Flash request boundary so other instructions and all other Google model/mode combinations retain their existing wire text.
…l aliases (lidge-jun#2966) * fix(responses): stop a namespaced MCP exec from authorizing bare shell aliases A namespaced tool named `exec` was aliased into the declared-name set under its bare name as well as its flattened wire name. That bare entry is not just a name: `normalizeDeclaredToolName` treats `declared.has("exec")` as the switch that maps an emitted `exec_command`, `shell_command`, or `apply_patch` onto the code-mode shell tool. An MCP server advertising its own `exec` therefore handed the turn a bare shell tool the request never declared, and the undeclared-tool guard accepted all three helper names. Withhold only that one bare alias when `exec` is declared inside a namespace. The flattened name still authorizes the tool, a namespaced call still matches by its full wire name, and every other inner name keeps the bare alias it had before. A request that separately declares a top-level `exec` is unchanged. * fix(responses): keep the merged guard set from re-adding the bare exec alias `buildToolBridgeMaps` also aliases a namespaced tool under its bare name when the caller's `tool_choice` selected it unambiguously, which the bridge needs to route the call back. `refreshUndeclaredToolGuard` merges that map into the same declared set, so a request whose only `exec` is an MCP tool selected by a bare `tool_choice` re-entered the normalization switch and the three helper names were authorized again. Admit that one alias only when the caller's own catalog declared a bare `exec`. Selecting an MCP `exec` is not a declaration of the code-mode shell tool. Bridge routing is untouched, so the namespaced call still returns under its bare selector. * fix(responses): preserve exec in Codex's reserved functions namespace Codex groups ordinary top-level tools under the reserved `functions` namespace. The namespace parser deliberately lowers those children without an MCP namespace, but the undeclared-tool collector treated `functions.exec` like an MCP tool and withheld its bare name. On a replay continuation, outbound and bridge-map names are intentionally not allowed to widen the current caller snapshot. The resulting set contained only `functions__exec`, so legitimate bare `exec` and its normalized helper calls were refused with 502. Treat the reserved namespace as the top-level container it is. Genuine MCP namespaces still withhold the bare exec alias, and a focused replay regression proves the current catalog authorizes bare exec after history expansion. * fix(responses): restore selected namespaced exec A namespaced MCP exec selected through a unique bare tool_choice was kept out of the guard's declared-name set, but its upstream bare call was then rejected with a 502. Restore only the request-bounded bare selector to its namespaced identity before guard inspection while keeping code-mode helper normalization disabled. --------- Co-authored-by: luvs01 <luvs01@hanmail.net>
Propagating owner grants and inheritance removal repeated on already-compliant secret trees, making startup cost grow with descendant count. Add an opt-in, single-path read proof that accepts only the cached effective token name with the exact explicit Full Control ACE and falls back on every ambiguity. Cache the token SID and resolved name together so mutation still grants by SID while readback can match icacls account names. Read-only success bypasses mutation without entering the post-mutation memo; the default flag-off sequence is unchanged.
…onstrained (lidge-jun#2967) * fix(config): start when proxy settings hold values the schema never constrained The top-level config schema ends in `.passthrough()` and declares neither `proxy` nor `noProxy`, so whatever is on disk reaches `applyProxyEnv` verbatim. It called string-only methods on those values, and it runs at every process entry point that makes outbound provider requests. A number, null, or object therefore did not degrade proxy behaviour -- it threw before the server could start. Four shapes were confirmed against the current code: noProxy: ["ok", 42] TypeError: entry.trim is not a function noProxy: ["ok", null] TypeError: null is not an object noProxy: [{a: 1}] TypeError: null is not an object proxy: 42 TypeError: value.match is not a function Ignore unusable values instead of throwing: they cannot express a routing intent, and refusing to start is a worse answer than starting without them. Array filtering is per-element so one bad entry no longer discards the operator's usable hosts, and loopback exclusions stay intact in every case. * fix(config): warn when proxy settings are discarded Malformed proxy settings previously degraded startup silently, allowing direct egress or unexpected proxy traversal without an operator signal. Emit privacy-safe warnings once per process for discarded proxy values, noProxy values, and invalid noProxy elements while preserving graceful fallback behavior. --------- Co-authored-by: luvs01 <luvs01@hanmail.net>
…d the envelope (lidge-jun#2940) * fix(cursor): keep a checkpoint suffix's completed pairs out of the orphan strip The orphan-strip guard in rootPromptMessages is premised on the replayed history starting where the CONVERSATION starts. That holds for a full replay and not for a checkpoint suffix, which starts at checkpointSuffixStart -- so its first entry is routinely the assistant message whose initiating user turn is inside the checkpoint. The loop read that as an orphan and shifted it off, then the next entry, and kept going: its break fires only once the survivors ARE the active block, so every completed pair was discarded. Measured 2 roots for 1, 2, 3 and 4 pairs in the suffix. Live on cursor/grok-4.6 that meant a growing conversation replayed a constant payload. Three sequential echo commands produced 14 tool executions -- STEP1 and STEP2 seven times each, STEP3 never -- with six was-interrupted narrations and no terminal answer, because the model could not see the output of the command it had just run. Diagnostics showed rawMessages 9,11,13,15,17,19 with rootBlobs pinned at 5. knownCallsOffset already carries the fact the guard was missing: only the checkpoint path passes one, and it passes suffixStart. Naming it suffixContinuesCoveredTurn and skipping the loop when set leaves the lidge-jun#1527 full-replay behaviour and the initiator recovery below it untouched. After the fix the same live run executes each command exactly once, narrates no interrupt, and answers ALLDONE; roots track history at 4, 6, 8, 10. Three assertions added, both mutation directions checked: restoring the unconditional guard turns two red, skipping it unconditionally turns the full-replay orphan case red. * fix(cursor): prune a checkpoint suffix incrementally and never overrun the envelope Audit r8 measured two further paths to the same symptom the orphan-strip fix addressed, and both are closed here. The orphan fix was INERT under byte pressure: 8 pairs of 64 KiB results emitted 2 roots with and without it. The keptPrior loop admits complete TURNS, and a turn starts at a user root -- which a checkpoint suffix does not have, by definition. So turnStart walked to 0, the prior block became one all-or-nothing pseudo-turn, the first budget overrun dropped all of it, and the orphan guard never ran. A suffix that continues a covered turn now admits entries individually: 2 -> 15 roots. Root replay is the only channel carrying suffix history -- conversationTurns opens no turn without a user message, measured 0 turns either way -- so this was a total loss. Restored growth then collided with the cumulative envelope guard, which began throwing a non-retryable 400 where the code used to degrade silently: 50 pairs behind 100 checkpoint roots, 10 behind 180, 4 behind 190, plus a cliff at 96 pairs. Suffix pruning now subtracts the checkpoint's own roots and bytes, and a checkpoint with no room left for its suffix is abandoned for a full replay under a new envelope_exhausted reason. Pruning to fit would have emitted the covered prefix and silently dropped every uncovered message, which is this unit's own defect at the top of the range. All three fixtures now stay at 191 roots, no throw, no cliff. Two tests asserted that throw; they assert the bound now, which is stronger -- the assembled request stays inside the envelope AND keeps its uncovered history. Removing the carriedRoots subtraction turns both red, so this is not a weakened expectation. The plan's live figures were recounted against the completed artifacts: 21 and 133 executions for three commands, not 14, and the turn does terminate. The earlier never-terminates claim came from reading a file mid-run and is withdrawn in the doc. * fix(cursor): decide checkpoint abandonment from pruning's result, and cover it Re-audit of the previous commit found the load-bearing half of it untested and one live gap left open. Both are closed here. The claim that the two rewritten envelope tests were mutation-checked against the carriedRoots subtraction was wrong. Both exit through the abandon branch -- the count case uses unmeasurable checkpoint roots, the byte case a checkpoint big enough to trip abandonment -- so neither touched the subtraction. Deleting it reintroduced all three throws with the suite still 97/0 green. A new case reaches it: measurable checkpoint roots, a count three below the limit so abandonment does not fire, and a suffix that only fits if pruning knows what the checkpoint spends. M1 now reddens three tests. The abandon threshold also left a live band. Comparing carried bytes against the raw limit kept the checkpoint a few hundred bytes below it while the suffix budget collapsed, silently dropping the newest tool result -- where the old code at least threw. Adding systemBytes moved the band instead of closing it. The decision now reads what pruning actually did. rootPromptMessages returns the source message index of every surviving root plus the indexes whose output truncation elided entirely, and the caller asks whether the message it is continuing from is in the first set and out of the second. Two earlier predicates are recorded in the devlog because each failed differently: matching the result's output text broke on JSON escaping and made every live turn abandon its checkpoint, and matching roles could not tell the result from the narration beside it. outputElided is set at the one place that can produce an answerless root -- the marker-only fallback, and a cut landing before the envelope's output: line. Both shapes were live in the band. Swept 15 positions from 100 KiB below the byte limit to 100 bytes above it: the newest result is present at every one, where five dropped it before. Live turns still resume from their checkpoint, so the predicate costs nothing on ordinary conversations, and the repro still runs each command exactly once and answers ALLDONE. * fix(cursor): scope the result-survival check to the path that can satisfy it Audit round 3 found the previous commit's predicate correct for external models and wrong for two other cases. Both were measured before changing anything. Native resume models were losing their checkpoint on EVERY tool continuation, including the default cursor/auto. Their result travels in server-side turn state, so echoToolResultInRoot is false and rootPromptMessages emits no toolResult root at all -- asking whether that root survived answers no unconditionally. pendingToolCalls, readPaths and previousWorkspaceUris live only in the checkpoint and full replay does not rebuild them, so this was the unit's own defect relocated to the native path. Measured readPaths 2 -> 0 for auto, composer-2.5-fast and composer-3 while composer-2.5 and grok-4.6 were unaffected, which is exactly the split cursorNeedsExternalToolContinuation draws; the check is gated on it now. Parallel results were protected one at a time. The check read only the last replayed index, and under byte pressure the OLDER results were the ones being emptied -- three calls, one answer, which the code's own comment calls worse than keeping nothing. historyOutputElided already recorded them and nothing read it. The whole trailing run of results is checked now: 628 swept positions went from 10 partial-answer positions to 0. envelope_exhausted reached nothing. It was assigned to a local, so it landed in the debug diagnostic while src/adapters/cursor.ts drops a dead checkpoint by reading request.checkpointInvalidationReason -- the exhausted checkpoint was re-decoded and re-abandoned every turn until TTL. Written back onto the request now, as request-builder.ts already does for every other reason. Three assertions added, each driven red against the implementation it catches. The parallel fixture's 375-byte offset is derived from the sweep, not guessed: it is the one position where a last-index-only check leaves exactly one answer standing. * fix(cursor): record the unpropagated invalidation reason instead of faking it Round 3 asked for envelope_exhausted to reach the checkpoint store. The obvious fix -- write the field back onto the request argument, which is what request-builder.ts does -- was implemented in the previous commit and is inert. live-transport.ts prepares a SPREAD COPY of the request, so the write lands on the copy and the outer object src/adapters/cursor.ts reads stays undefined. Measured directly: copy sees envelope_exhausted, outer sees undefined. So the write is removed and the limitation is documented at the site instead. Reaching the store needs the reason threaded back through PreparedCursorRunRequest, a signature change on the shared prepare path that belongs to its own phase. The cost of leaving it is bounded and stated: the checkpoint is re-decoded and re-abandoned each turn until TTL, which is wasted work rather than wrong output. The accompanying test asserted request.checkpointInvalidationReason, which would have passed on the argument while proving nothing about the real path -- the same vacuous coverage round 2 caught. It now asserts what is actually observable: an exhausted checkpoint still assembles a legal full-replay request that carries its own history. * fix(cursor): gate both result-survival disjuncts, not just the last one Round 3 gated the result-survival check on cursorNeedsExternalToolContinuation. The abandon condition is a three-way disjunction and only the last term was gated. The middle one -- the suffix produced no history roots at all -- asks the same question, whether a replayed root went missing, so it is equally meaningless for a model whose results never become roots. It fired whenever a native assistant turn was a bare tool call with no narration: no text root, no result root, zero history roots, checkpoint discarded. Measured on that shape, readPaths went 2 -> 0 for auto, composer-1, composer-2.5-fast and composer-3 while composer-2.5 and grok-4.6 were unaffected -- the same split and the same loss as round 3's blocker, one disjunct over. The count-full term stays ungated because it is a real envelope fact independent of who echoes results. The test that let this through was round 3's own: it asserted the native path with narration, so the narration-free shape of that path stayed invisible. It is a cross product now -- four model ids by four assistant shapes (narrated, silent, empty text, whitespace text) -- because that is the axis these bugs keep hiding along. Restoring the ungated disjunct reddens it. Also corrects two counts in the devlog: the four-suite total is 188, not 187, and the three-suite figure is 138 at head rather than the 133 true when written. Both were flagged by the audit; neither matched any commit in the stack. * fix(cursor): bound the trailing result run by count, not only by bytes historyLimit already subtracted the roots a checkpoint carries, but it was consulted only by the prior-history admission loop. The trailing tool-result block was assembled under byte pressure alone and concatenated with no count check, so for the ordinary checkpoint-continuation shape - empty keptPrior - the payload size was bounded by nothing. truncateToolResultBlob cannot help: it frees bytes, never a root slot. The abandon condition was meant to catch the overflow and tested carried plus system count, which asks whether there is room for ONE more root. A parallel tool-call batch needs active.length of them: 190 carried roots plus a 3-result batch assembled 193 and threw a non-retryable 400, 188 plus 8 threw 196. Reachable by ordinary growth - replaying each turn's state as the next checkpoint, 3 calls per turn died at turn 48 and 5 at turn 32; all shapes now survive 200 turns. Bound active where it is assembled instead of adding a disjunct that must predict the suffix width. Oldest results drop first, matching byte pruning, and one always survives so the abandon check can see the loss through historyMessageIndexes and fall back to a coherent full replay. Sequential fixtures hid this: their trailing run is always length 1, the one width where the old test was exactly right. The parallel sweep used the byte axis, where abandonment fires first. All 188 tests passed with and without the fix; the three new count-by-parallel rows are the first to redden. * fix(cursor): make the count drop visible to the check that guards it The count bound added for the previous round acts on root entries; the abandon check derived its trailing tool-result run from raw messages. Those spaces diverge on the most ordinary assistant shape there is: a bare tool call with no narration emits no root, so two sequentially-executed results become adjacent roots while raw space still separates them. Both results therefore entered the root-space run, the bound dropped the older one, and the raw-space scan saw a run of length one - the newest result, which survived - and reported "kept". The checkpoint was retained and the request went out with a tool call answered by nothing: measured at 190 carried roots, the first answer was in no root and in no turn, with no throw and no diagnostic. The model's only sensible response is to re-issue the call, which is the loop this unit exists to end. rootPromptMessages now reports activeMessageIndexes, the trailing run as pruning saw it, recorded before pruning can shrink it, and the abandon check reads that instead of re-deriving a run it cannot see. It falls back to the raw scan when the field is empty, so full-replay and native shapes keep their behaviour. The drop was also unnecessary. historyLimit subtracted systemEntryCount on a path where the caller appends only ids.slice(suffixSystemCount) and the checkpoint's own system roots already sit inside carriedRoots.count, so one free slot was charged twice: the limit came out 1 where 2 results fit. Mutation evidence: raw-space check 2 red, double charge 1 red, the previous bound removed 5 red. The first silent-loss test passed with the double charge still present, because that defect abandons the checkpoint and a full replay carries every answer - correct output reached wastefully. Pinning it needed a second case asserting the exact root count at exact fit. Also covers outputElided on the marker-only return, which had none: removing the flag left all 191 tests green. * fix(cursor): keep the repetition note from hiding the trailing results The trailing-result walk tested only for a toolResult role and started at the very end of history. The repetition breaker appends a synthetic [context note] user root after the transcript when the same output repeats three or more times, and that note stands for no message, so it carries no messageIndex. The walk hit it and stopped: activeStart came out equal to history.length, the trailing run was empty, activeMessageIndexes was empty. Two failures followed, both worse than the one the previous commit fixed. The results lost trailing-run status entirely, falling through to prior history where the keep-at-least-one floor does not apply. And the empty field sent the abandon check into its raw-space fallback, the scan the previous commit exists to avoid: at 186 carried roots the note-armed shape was retained where the identical shape without the note correctly abandoned. The trigger is the worst one available. The note arms on three consecutive identical assistant narrations, which is the runaway-repetition shape this unit exists to end. The walk now skips trailing roots with no messageIndex before looking for the result run, and those roots are re-appended afterwards so the note still reaches the model. A root added after pruning must be paid for during pruning: left uncharged, note-armed continuations at 188-190 carried roots threw the non-retryable 400 for sequential and parallel suffixes alike, so syntheticCount and syntheticBytes are charged in the count bound, the prior-history loop and the byte accounting, and the orphan-strip floor counts them so the strip cannot eat into the run. Also drops chargeableSystemBytes. Review found it had no coverage, and no configuration could be found where relaxing the byte budget changes the payload - six crossings in the deciding band were byte-identical either way. Charging system bytes twice only errs conservative. The count relaxation stays; its case reddens without it. Mutation evidence: messageIndex walk 1 red, syntheticCount in the count bound 1 red, note dropped from the payload 1 red, syntheticCount in the prior loop 2 red. The two charges had no failing test at first, which is the same condition the previous round was caught on. * fix(cursor): keep the repetition note out of the pruning decision The previous commit re-appended the note into historyEntries before the pruning blocks ran, so each of them had to recognise a tail it could only identify by position. The initiator-recovery block could not: its floor stops when one entry remains, so with [toolResult, note] it counted the note as the survivor and shifted off the result. With one 600 KB result and three identical narrations instead of two, the model received 193 bytes of "take a DIFFERENT action" and no tool output at all. The result had already been truncated to fit; it was deleted anyway. That is the reported symptom exactly - no output, so the model runs the command again - re-entered through the fix for it. A second mechanism compounded it. activeBytes included syntheticBytes while the equal-share divisor did not, so shares summed to the whole budget and adding the note back always exceeded it. The shrink-instead-of-drop pass became structurally unfittable and fell through to deleting a whole result: 246 bytes of note cost a 200 KB answer. Review measured 166 of 432 byte-pressure configurations losing an answer. Rather than add the tail's length to each floor - which works and leaves the next block to find the same trap - the tail is held out of historyEntries until assembly, and every budget is expressed net of it. historyLimitForReal and historyBudgetForReal are computed once, before the first result is measured, so the pruning blocks reason only about real history. An intermediate version that held the tail out without reserving its bytes committed 51 bytes over the limit, which is why the reservation is separate from the hold-out. Also covers the byte reservation, which review found had no test at all while a sweep against its removal threw 148 envelope errors. Mutation evidence: byte reservation 2 red, count reservation 3 red, note dropped 4 red, messageIndex walk 3 red, and the full r12 defect - re-append plus gross budget - 2 red. Re-appending alone is now harmless because the reservation prevents the loss by itself. * fix(cursor): drop the repetition note when the envelope cannot pay for it The reservation was a subtraction clamped at zero while the append was unconditional, and those are compatible only while the difference is non-negative. Below that the clamp reports that the note costs nothing, every pruning block correctly reasons about a budget of zero and emits nothing, and the note is appended regardless - so the payload lands over the limit by exactly the deficit the clamp erased. With 26 bytes free and a 246-byte note: 220 bytes over, non-retryable 400. Holding the tail out of historyEntries is what made it unrecoverable, since no block below could see it to charge it. Every fixture missed this because the exposed shape is a turn that does not end in a tool result. With a trailing result the abandon check's survival disjuncts rescue the turn; on a plain user interjection they structurally cannot. Across 42 carried-byte positions: 13 throws with the note armed, none without, all on the interjection tail. Affordability is now decided before the reservation, and an unaffordable note is dropped - this unit's own priority order, since a missing instruction is recoverable and a missing tool result restarts the loop. The first version of that test also required a free root slot. It could not be made to matter: 60 boundary positions at and past the root limit behaved identically either way, because the count bound already stops at one surviving result. Removed rather than shipped, for the same reason chargeableSystemBytes was - an envelope condition that cannot fail is indistinguishable from one that is wrong. Also corrects one activeBytes gate that read the gross budget while its body wrote the net one. No behavioural difference, but it is the drift that seeded two earlier rounds. Mutation evidence: affordability removed 3 red, tail appended regardless 3 red. * fix(cursor): restore the count half of the note affordability test The previous commit dropped it as inert, reasoning that the count bound below always leaves a slot free because it keeps one result. That holds for every value of historyLimit except 1, where the one free slot is precisely the one the surviving result takes. The note was then judged affordable on bytes alone, the reservation clamped to zero, and the append pushed full replay to 193 roots: four armed-only envelope throws at 191 system prompts, across both tails and both suffix widths, where the same request without the note assembled 192 and succeeded. Full replay has no abandon branch to rescue it. The sweep that supported "inert" varied carried roots on the checkpoint path, where the count-full disjunct abandons long before historyLimit can reach 1. The reachable route is full replay with many system prompts - a different axis. Inert across 60 positions was a true statement about the wrong sixty. Also closes a coverage gap review found in the same area: the affordability check was pinned at the append site only. Neutering syntheticCount and syntheticBytes while leaving the append gated left the suite green, because asserting on the assembled payload cannot separate "the deficit was charged" from "the tail was not appended". Asserting the exact root count at the boundary separates them. Mutation evidence: count conjunct 4 red, byte conjunct 3 red, reservation neutered 6 red, append ungated 7 red. * test(cursor): pin the note affordability threshold from the tight side Review found the bound was only constrained from the loose direction: relaxing historyLimit - syntheticCountRaw >= 1 reddens four cases, but tightening it to >= 2 left all 212 green. Over-conservative is safer than over-eager, but a suite that cannot tell a correct bound from an unnecessarily strict one is the gap that cost the previous round. Two free slots is the tight case - the result takes one, the note takes the other - and the new case asserts both arrive at exactly 192 roots. Relaxing the bound now reddens 4, tightening it reddens 1. Also corrects the record. The previous commit message claimed the reservation had been pinned at the append site only, and that neutering syntheticCount and syntheticBytes left the suite green. On the parent that mutation already reddens 6, all pre-existing cases from earlier rounds. The count-conjunct finding stands on its own evidence; that secondary claim did not. The devlog records the remaining known gap: on the extreme byte axis a ~523 KB system prompt can keep the note while the result truncates to a marker, inverting this unit's priority order. Identical on the parent and far worse on dev, so pre-existing and improved here, but a genuine follow-up.
…#2964) * fix(test): install gui dependencies the local runner already needs `gui` is not a workspace of the root package and declares React only in `gui/package.json`, so a root `bun install` never creates `gui/node_modules`. Twenty-five files under `tests/` import modules from `gui/src`, so on a fresh clone or worktree those tests fail with `Cannot find package 'react'` — reported as an "Unhandled error between tests" that names no test, which is why this has read as a flake rather than a missing setup step. `.github/workflows/ci.yml` already installs them explicitly, and its comment says why: "Several files under tests/ import JSX-bearing modules from gui/src ... and React is declared only in gui/package.json." The local runner had no equivalent, so `bun run test` and CI did not agree about what the suite needs. Verified as an A/B on a worktree with only the root install: the same gui-importing test goes 0 pass / 1 fail before and 4 pass / 0 fail after, with the directory installed on the way through. Installing rather than failing is the deliberate call — `gui/node_modules` is a gitignored build artifact, not source, and the tests genuinely require it. The bounds matter more than the convenience: it runs only when `gui/package.json` exists and `gui/node_modules` does not, so a published install tree with no `gui/` is untouched; it uses `--frozen-lockfile` to match CI; and a failed install aborts with the manual command instead of continuing into twenty-five unexplained React failures. This closes the deterministic half of the problem. A separate intermittent resolution failure still occurs at full-suite scale with the directory present, which does not reproduce at smaller scale and is not addressed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): detect incomplete gui dependency installs Treat React's package manifest as the install-completion marker so an interrupted gui install is retried instead of being accepted because node_modules exists. Normalize mocked path suffixes and exercise both separator styles so the focused runner tests remain valid on Windows. --------- Co-authored-by: olddonkey <olddonkeyblog@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…e-jun#2970) The eight-phase Cursor tool-continuation unit is finished and its work is visible in public git history: the last phase landed on dev as 62df78d (PR lidge-jun#2940). AGENTS.md puts a unit in _fin once a terminal outcome is recorded and the work it describes is already public, so the unit moves there rather than staying an open plan. 070 gains a Terminal outcome section naming the merge commit, the post-merge remote gate at that commit (exit 0, 16359 pass / 0 fail / 16 skip, so the landed tree is verified and not only the pre-merge head), the round 11 PASS verdict with the two notes it left, and the three items scoped out on purpose - the inert envelope_exhausted propagation, the extreme-byte-axis note ordering, and composer-2.5's hybrid root count, each pre-existing rather than introduced here. No source, build, typecheck or test path reads devlog/, so this changes no runtime behaviour.
…idge-jun#2955) With the retry guard off (the default) an empty turn is reported through a `console.warn` built by interpolating `route.providerName` and `route.modelId` directly. Both come from the request, so a model name carrying CR/LF or terminal escapes turned one notice into several apparent log records: measured against current dev, a hostile model name produced 3 lines carrying an ANSI escape. Move the sentence into `emptyCompletionNotice` next to the observer it describes and reduce both labels with the existing `sanitizeLogMetadataString` helper, the same treatment the neighbouring fast-wire warning already uses. An unusable label degrades to a stated `unknown` rather than an empty slot in the sentence.
…ge-jun#2968) * fix(claude): use Windows paths for policy probe * test: encode external prompt path fixture * test(service): handle escaped Windows paths in unit assertions * test(windows): make startup health expiry deterministic
…rmatted (lidge-jun#2965) * fix(quota): drop expiry timestamps that no date formatter can render `epochMillis` accepted any finite number, but finite is not the same as representable. ECMAScript caps time values at ±8.64e15 ms, and `Intl.DateTimeFormat.format()` throws a RangeError past that rather than rendering an approximation. So a provider reporting a bogus expiry did not produce a wrong date; it produced a value that faults every consumer that formats it. Measured against the current code, 1e20 and 1e16 both yield "date value is not finite in DateTimeFormat format()", while 8.64e15 remains representable. Resolve an unrepresentable value to undefined so it never enters a report. Seconds inference, the zero/negative sentinel handling, and every representable timestamp are unchanged. * fix(gui): keep the capacity panel when a credit expiry cannot be formatted `Intl.DateTimeFormat.format()` throws a RangeError on a time value outside ±8.64e15 ms rather than rendering an approximation. The capacity panel formatted `expiresAt` and `nextRecoveryAt` directly, so one unrepresentable timestamp did not merely show a wrong date — it aborted rendering and removed the credit balance, the recovery rows, and the aggregate view along with it. The wire-side guard stops such a value entering a fresh report, but persisted reports predate it and the component is reachable without that path. Guard both layers here: normalization drops the field, and the component omits the line instead of formatting it. Representable timestamps and seconds inference are unchanged. * docs(assets): add the capacity-panel before/after screenshot Server-rendered comparison of the provider capacity panel when a provider reports an expiry outside the representable date range, following the repository's existing convention of committing PR screenshots under assets/. * fix(quota): preserve xAI usage without reset time An unrenderable weekly reset timestamp caused the credits parser to discard an otherwise valid usage percentage. Validate the percentage independently and omit only the invalid reset timestamp so the preferred weekly meter remains available. * fix(gui): hoist quota date helper --------- Co-authored-by: luvs01 <luvs01@hanmail.net>
…ge-jun#2971) The _fin move in lidge-jun#2970 landed as a pure rename: 10 files, 0 insertions. Its commit message described a Terminal outcome section appended to 070, but that edit was unstaged when the commit was taken, so git recorded only the staged rename and the section never reached dev. The message therefore claims a change the tree does not contain. This adds the section for real. It names the merge commit 62df78d, the post-merge remote gate at that commit (exit 0, 16359 pass / 0 fail / 16 skip, so the landed tree is verified and not only the pre-merge head), the round 11 PASS verdict with the two notes it left, and the finding that rounds 5 through 10 were each triggered by the previous round's own fix - every one of those fixes added a fact to the pruning code without asking which existing block had assumed that fact absent. It also names the three items scoped out on purpose, so a later reader does not reopen them as defects of this unit: the envelope_exhausted reason that a spread copy in live-transport.ts makes provably inert, the extreme-byte-axis case where the repetition note can outlive the result, and composer-2.5's hybrid root count. The last two are identical on dev before this unit and are pre-existing rather than introduced. Docs only. Nothing in the build, typecheck or test path reads devlog/.
…ge-jun#2974) * docs(devlog): plan the green-PR merge train with overlap-derived ordering Eight rebased PRs reached a green technical matrix. Merging them in arrival order would be unsafe: five pairs share a src/ file and three of those share src/config.ts, so a later merge would resolve conflicts blindly. The unit records a mechanically computed overlap matrix, a collision-degree ordering that lands lidge-jun#2854 last because it is the only PR bridging two clusters, per-merge rebase mechanics, and designs for the two PRs that cannot merge as-is: lidge-jun#2429 trips privacy:scan on an email literal, and lidge-jun#2827 ships a response header no browser can read. * docs(devlog): plan the release-readiness train from the 260830 snapshot Eight bug-class pull requests and fifteen issues were audited against dev@47b8d1643 in isolated worktrees. Only two of the eight merge as-is; five need a repair commit and one needs reimplementing, and of the fifteen issues only two are fixable now — the rest need a measurement nobody has taken or a design cycle. The unit records the computed file-overlap matrix (three collisions, one of them an identical commit shared by two PRs), the merge order those collisions dictate, and one decade doc per work phase. It also records the Windows mechanic that shapes the train: platform-windows only runs on workflow_dispatch, and ci.yml concurrency is keyed on github.ref, so a merge to dev cancels a dev-targeted dispatch. * docs(devlog): correct the train plan against two audited blockers A plan audit returned FAIL on two counts. The baseline Windows dispatch was scheduled against dev, and ci.yml keys its concurrency group on github.ref for workflow_dispatch as well as push, so merging lidge-jun#2952 cancelled all four windows shards twenty seconds later. Every Windows dispatch now targets a dedicated branch ref, and the cancelled baseline is recorded as unavailable rather than quietly replaced by the later run. The second blocker: pushing a repair commit to a contributor PR resets the enforce-target readiness checklist and returns the PR to draft, and that checklist is an author attestation a maintainer must not tick on their behalf. Four PRs are in that state. The docs now carry the maintainer-owned carry-PR path -- cherry-pick preserves author metadata -- instead of merge steps that cannot execute. * docs(devlog): record the release-readiness train outcome Ten pull requests landed, two issues closed, five contributor PRs closed as carried or superseded, and four Windows-only defects fixed that no push run could have seen -- platform-windows only runs on workflow_dispatch, so the CI that gates release.yml covers Linux, macOS and the gates and nothing else. None of the four Windows failures was a product defect. Four tests were asserting things that are false on a platform the project supports. The prompt-route case needed two passes and is recorded in full: the first fix used a posix filename containing a literal backslash, which is a filename character on POSIX and a separator on Windows, so the fixture parent directory never existed and the case failed earlier rather than passing. The duration falling from 243ms to 4ms is what identified it.
…unit (lidge-jun#2977) The unit's eleven audit rounds all reviewed the change while it was being built, against a plan the same session wrote. This adds 080, the record of a narrower gate those rounds structurally could not perform: whether the landed code on dev holds up to a reviewer who did not build it, and whether every claim in the written record is true against git rather than against memory. Verdict: pass, no findings. The gate produced one correction worth having. The fix does not emit a separate assistant [Tool Call] root before a replayed result - it names the invocation inside the result envelope as an "invoked:" line, because a standalone marker gets few-shot-mimicked by the model and breaks multi-tool continuations, which is what the remote suite's 363-B guard caught when this unit first tried that shape. So the invariant is "no replayed result root lacks its invocation line", and the earlier call-precedes-result framing is misleading. Coverage is measured rather than asserted: six mutations each reddening on-point tests, and the round 11 note threshold reproducing in both directions - relaxing it reddens 4, tightening it reddens 1. A suite that can tell a correct bound from an unnecessarily strict one is what rounds 5 through 10 lacked. Three hostile shapes also attacked the checkpoint skip premise directly and none produced an orphan, because the invocation is keyed by call id over full history. It records one behaviour without filing it: when two calls share a decoded call id the ambiguous id is dropped and both results replay with no invocation line. The code argues that tradeoff explicitly and upstream call ids are unique. Docs only. Nothing in the build, typecheck or test path reads devlog/.
…e-jun#2975) * test(service): make retargeted shim fixture cross-platform * test(init): synchronize EOF check with first prompt * test(storage): handshake after restore file moves * test(storage): wait for cleanup slot acquisition
…om model quota (lidge-jun#2976) * fix(quota): correct GLM Coding Plan auth and stop reporting MCP calls as model quota Closes lidge-jun#1168. Two defects in the BigModel Coding Plan quota probe: 1. open.bigmodel.cn expects the API key directly in Authorization with no scheme prefix and answers a Bearer header with an auth error, so BigModel Coding Plan quota never rendered at all. api.z.ai keeps Bearer. The host is already canonicalized by isCanonicalZaiBaseUrl and redirect stays 'error', so the bare key cannot reach a lookalike host. 2. TIME_LIMIT rows were mapped to monthlyPercent, but they are the shared monthly MCP *call* allowance for search-prime / web-reader / zread, not a model-token window. This was not merely a mislabelled bar: headroomOf() in src/oauth/account-quota-rank.ts takes the MAX across every window, so a user who spent their web-search allowance had that read as exhausted model capacity and a healthy account was demoted in quota-aware ranking. Row type is now gated before a percentage is derived, so an MCP row cannot contribute a value at all, and a payload carrying only TIME_LIMIT rows reports no quota rather than a fabricated one. Absent windows stay absent instead of being synthesized as 0. Tests: existing Z.AI/BigModel cases updated to assert the raw-key header and absent monthly window, plus two new regressions — a fully-spent MCP allowance yields no report, and the real V1 Lite shape keeps weekly/monthly absent rather than 0. * test(quota): keep the legacy Z.AI monthlyMCPUsage mapping intact The legacy field-name fallback is a different contract from the limits[] parser: it reads an explicit monthlyMCPUsage field from an api.z.ai payload, where mapping to monthlyPercent is the pre-existing documented behavior. Issue lidge-jun#1168 scopes the TIME_LIMIT exclusion to the limits[] parser only, so parseZaiQuotaLegacyFields stays untouched to avoid regressing older responses. * fix(quota): distinguish an authoritative-empty GLM response from a probe failure Review finding on 1f33ddc: after the TIME_LIMIT exclusion, a SUCCESSFUL limits[] response carrying only MCP rows returned null, which fetchProviderQuotaReports treats as a transient probe failure and answers by preserving the last-good report for up to 30 minutes. The dashboard and quota-aware routing therefore kept using stale model-token windows even though the new authoritative response said there are none. Adds an AUTHORITATIVE_EMPTY_QUOTA sentinel beside the existing TERMINAL_QUOTA_FAILURE and routes it through the same suppression path, so the provider's last-good row is dropped. The distinction is the point: null means 'this probe told us nothing, keep the old row'; the new sentinel means 'the provider answered, and the answer is no model windows'. Malformed, transport, and 5xx outcomes stay on the transient-preservation path, and the legacy flattened Z.AI parser keeps returning null when it cannot read a payload. Regression runs two sequential forced refreshes — token windows, then MCP-only — and asserts the cached report is cleared rather than preserved.
…lified reading (lidge-jun#2987) * fix(slug): resolve self-namespaced native ids before the provider-qualified reading * fix(cli): resolve models remove against the whole provider roster
lidge-jun#2498) (lidge-jun#2985) * feat(xai): expose grok-4.20-multi-agent on the Responses lane The registry excluded grok-4.20-multi-agent-0309 because "the OAuth chat-completions transport returns 400 (Multi Agent requests are not allowed on chat completions)". That is a statement about the Chat wire. The Responses lane exists now and the model works on it. Measured 2026-08-23 against cli-chat-proxy.grok.com: POST grok-4.20-multi-agent-0309 -> 200, response.model = ...-0309 POST grok-4.20-multi-agent-beta-latest -> 200, response.model = ...-0309 So beta-latest is a floating alias and xAI itself names the dated id as the deployment. Only the dated id is exposed. GET /v1/models differs sharply by destination: the OAuth CLI proxy lists just grok-4.5 and grok-4.6, while api.x.ai lists twelve including the dated multi-agent id (beta-latest appears on neither). The model is therefore callable but unlisted on the OAuth route, and authoritative discovery drops configured ids it does not return. It joins CALLABLE_CONFIGURED_COMPATIBILITY_MODELS, where the other xAI models in the same position already live — one line, and a configured id that is genuinely absent is still dropped. modelWireDefaults pins it to openai-responses under both auth modes: with no working Chat wire, exposing it unpinned would fall back to the wire that 400s. Context window is 1M and modalities are text+image, from the dated row rather than the alias's 2M/30k. The generated metadata table was regenerated from scripts/model-metadata.source.json rather than hand-edited. Deliberately NOT encoded: the model emits no reasoning-summary deltas and no encrypted replay material even at high effort. Recording that as modelSupportsReasoningSummaries:false would derive a false catalog bit (provider-fetch.ts:628-650) and, through Codex lidge-jun#1100, suppress the entire reasoning object — including the effort that controls this model's agent count. A test asserts the capability stays undefined so a well-meaning future edit cannot reintroduce it. It is also not added to XAI_RESPONSES_OPT_IN_MODELS: that toggle means "these two models switch wire", a real user choice, and multi-agent has no wire to switch. * fix(xai): keep multi-agent on Responses and hide the beta alias Chat Completions returns 400 for grok-4.20-multi-agent-0309, so the registry default now covers chat inbound as well as Responses. Live /models still advertises grok-4.20-multi-agent-beta-latest; drop that exact id from the xAI catalog. Leave supportsReasoningSummary unspecified so Codex keeps the effort field that selects agent count. * fix(xai): route multi-agent to Responses on the Anthropic inbound too The entry's own comment already said "Chat Completions returns 400 for this model, so every inbound uses Responses", but the allow-list held only ["responses", "chat"]. `anthropic` is the third member of InboundWire, and the Claude Messages lane resolves with it (src/server/claude-messages.ts:686). An inbound missing from that list is not a no-op: providerModelWireDefault returns undefined (registry.ts:2922), so resolveWireProtocolOverride never substitutes an adapter and silently keeps xAI's provider-wide `openai-chat` (registry.ts:1082) — the exact wire this model answers with a 400. Claude Code talking to grok-4.20-multi-agent-0309 therefore hit Chat Completions. The existing regression test covered responses and chat for both auth modes, which is why the gap survived; it now covers anthropic as well. Restoring the old allow-list turns that test red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(xai): clamp multi-agent Responses effort --------- Co-authored-by: olddonkey <olddonkeyblog@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…tion count (lidge-jun#2954) * fix(codex): bound the provenance ledger by bytes, not only by transaction count A `present` baseline embeds the artifact's exact bytes as base64, and the native Codex artifacts sit outside this proxy's trust boundary. The 16-transaction window bounds the ledger only while each transaction is small, so a single oversized `config.toml` was copied into integrations/codex.json in full and re-serialized on every append. Measured against current dev, three admitted transactions over a 64 MiB config produced a 576 MiB ledger write. Add a 1 MiB serialized ceiling alongside the existing window. Transactions are admitted newest-first and only whole, matching the existing rule that a partial transaction is worse than none. A transaction that cannot fit is skipped rather than ending the scan, so one pathological artifact cannot erase the smaller, still-diagnosable evidence around it. * fix(codex): keep provenance bounds intact through extension merge The initial byte ceiling was smaller than the ordinary 16-transaction window described by the same file, measured compact transaction arrays while the writer pretty-prints the record, and could serialize an oversized base64 payload merely to decide that it did not fit. Raise the ceiling to 4 MiB, preflight embedded pre-image length before serialization, and measure the exact pretty-printed provenance wrapper including its final newline. Group transactions once and preserve the original array instance when the ordinary window fits. Also fence integration-record extension preservation by transaction identity. When bounding omitted a transaction, the positional fallback could attach that transaction's unknown extensions to the new entry that shifted into its slot, falsifying provenance and regrowing the final record past the ceiling. * fix(codex): charge ledger extensions to the provenance byte budget The writer preserves forward-compatible fields directly under `provenance`, but the byte bound measured only a synthetic ledger containing `entries`. A large unknown ledger extension therefore consumed no budget and could keep every subsequent append above the ceiling. Let the bound receive the existing ledger template and replace only its entries while measuring. The production append passes that template explicitly, so ledger-, entry-, artifact-, and baseline-level extensions are all charged in the same pretty-printed shape that will be written. * fix(codex): refuse irreducibly oversized provenance writes If forward-compatible ledger fields alone exceed the byte ceiling, no entry selection can make the final write compliant. Returning an empty entry set would delete every known receipt while extension preservation still rewrote the same oversized file. Detect that fixed overhead before trimming and return the exact existing record. `updateIntegrationRecord` now treats same-object return as an explicit no-op, skipping validation, extension merge, directory creation, and atomic rewrite. The production regression proves the append returns safely while the record's entry count and exact bytes remain unchanged. * fix(codex): backfill the bounded provenance window * fix(codex): bind provenance extension preservation to artifact identity
…der false (lidge-jun#2978) * feat(adapters): annotate present-but-empty tool outputs (DeepSeek default) * fix(adapters): annotate whitespace-only text-part arrays on the chat wire * test(adapters): cover orphaned empty tool results on the chat wire * docs(types): end the annotateEmptyToolOutputs comment sentence * fix(adapters): never annotate non-text Responses tool outputs; shared emptiness contract; auth-cors boolean guard * fix(adapters): treat Responses input_text/output_text parts as wire text for the emptiness contract * fix(adapters): annotate empty tool outputs before stateless orphan repair * fix(adapters): never annotate missing/null tool outputs; pin DeepSeek backfill * fix(management): validate annotateEmptyToolOutputs off the auth surface * fix(management): redact provider name in annotation error; support PATCH field * fix(management): keep an explicit annotateEmptyToolOutputs: false through a provider POST The add/edit payload does not always carry annotateEmptyToolOutputs, and DeepSeek has a registry default of true. POST rebuilds the provider from the request body alone, so an unrelated edit — rotating the API key, say — arrived without the field, registry enrichment filled true, and the write replaced the stored row without ever reading the operator's explicit false. The annotation silently turned back on. Fixed with the ownership-sampling pattern this file already uses for requestPacing and contextWindow: sample Object.hasOwn BEFORE enrichment, because afterwards 'the client omitted this' and 'the registry supplied it' are indistinguishable. The carry-over test is !== undefined rather than truthiness, since the value being defended is false. PATCH already handled this; only POST/reload was affected. Regression asserts all four cases: explicit false persists, an omitting overwrite preserves it, the runtime resolution through routedProviderConfig still yields false, and an explicit true still wins. * fix(management): let canonical OpenAI own the annotateEmptyToolOutputs overlay * test(management): exercise the canonical seed path in the PATCH regression * test(management): target the real PATCH route in the canonical regression * docs: describe the annotateEmptyToolOutputs provider option Review finding: the PR added a user-facing provider option with a DeepSeek default of true and an explicit false opt-out, but no documentation named the key, its accepted values, its default, or how to clear it. * docs: localize the annotateEmptyToolOutputs provider contract Review finding: the option shipped with a DeepSeek default of true and an explicit false opt-out but was documented only in English. All seven translated provider references now state the default, the opt-out, and that PATCH null clears the override. --------- Co-authored-by: HarryZhou <2373256746@qq.com>
…ients (lidge-jun#2979) * feat(server): add least-privilege GET/HEAD /v1/catalog for remote clients Closes lidge-jun#809. A remote Codex client needs the model catalog, and the only source was GET /api/catalog behind management auth — so operators had to hand out an admin token to read a list of models. This adds the read on the data plane instead of widening /api/*, which stays exactly as restricted as before. Admission uses resolveApiAuth, the same as /v1/models and for the same stated reason: the route forwards no caller credential upstream, so the dedicated header, a recognized bearer, and x-api-key are all safe. Using resolveResponsesApiAuth would have 401'd Anthropic-SDK clients holding a valid data credential, since that transport deliberately rejects x-api-key to avoid a credential collision it does not have here. src/server/catalog-download.ts is shared with the management route so both planes serialize identical bytes; an independent second serializer would drift, and the data-plane copy is the one nobody sees in the dashboard. It adds a 32 MiB ceiling, a SHA-256 ETag with conditional 304, and Cache-Control: private, no-cache for credentialed identity-varying content. HEAD returns identical status and headers with no body. The Codex version header is passed through when authoritative and omitted rather than fabricated when not. * test(server): cover the /v1/catalog data-plane contract and plane separation * test(server): bind non-loopback so the /v1/catalog auth assertions are real * docs: point remote catalog fetch at the data-plane /v1/catalog route The Codex integration guide told operators to fetch the catalog with OPENCODEX_ADMIN_AUTH_TOKEN against /api/catalog — which is exactly the least-privilege problem lidge-jun#809 describes. English and all seven locales now use an ordinary data-plane key against /v1/catalog, and state that a data key admitted there gains nothing on the management plane. * fix(server): scope the catalog size ceiling to the remote route Review finding on 63edd4b: the 32 MiB ceiling lived in the shared serializer, so it applied to /api/catalog too. The repository supports up to 2,000 discovered models and a 2,000-row catalog serializes to roughly 92 MB — the ceiling therefore rejected a valid supported catalog AND regressed the pre-existing management route to 507 for those operators. A size policy belongs to the route that serves the bytes, not to the shared serializer both planes depend on. serializePersistedCatalog no longer caps anything; /v1/catalog applies MAX_REMOTE_CATALOG_BYTES itself, raised to 256 MiB so the supported bound clears with room while a corrupt or hostile file is still refused. /api/catalog keeps its original behavior exactly. Regression builds a valid 2,000-model catalog above 32 MiB and asserts the serializer returns it and the management route still answers 200. Docs: locale integration intros still described a management-API fetch with an /api/* admission token while the command below used /v1/catalog, and the management reference said 'needs no admin token', which reads as anonymous. Both now say an ordinary data-plane credential is required and distinguish it from admin privilege. * docs: state the real catalog credential in the fr and zh-tw guides Review finding: the French sentence contradicted itself mid-clause — it said an ordinary data-plane key and then called it the same admission credential as other /api/* routes. The Traditional Chinese one still described a management-API fetch with an /api/* admission token. Both now use the same explicit contract as the Japanese, Korean, and Simplified Chinese pages: an ordinary data-plane credential, the same one used for /v1/responses, not a management or admin token.
…end budget (lidge-jun#2981) * fix(upstream-retry): make attempts one total-send budget across both retry layers fetchWithTransientRetry forwarded its whole opts object, attempts included, into every fetchWithResetRetry call, so the two layers multiplied: attempts:3 allowed 3 transient rounds each independently retrying 3 connection resets, for up to 9 upstream sends, and attempts:10 allowed up to 100. The existing doc comment already flagged the hazard and noted it was inert because 'no caller passes it today'. The provider-level transientRetryOn5xx policy in lidge-jun#2643/lidge-jun#2655 is the first caller that does, which would have turned a latent note into live behavior — and multiplying load against an already-failing provider is worse than not retrying at all. A counted fetch wrapper now increments a shared send count before each await, and only the remaining budget is passed inward. Recovery labels, evidence wrapping, backoff, Retry-After, cancellation, slow-attempt return, and terminal-body preservation are unchanged. * test(upstream-retry): pin the total-send budget with mixed resets and 503s * test(upstream-retry): assert the shared budget with correct terminal shapes * feat(providers): add opt-in transient-5xx retry for key-auth openai-chat Closes lidge-jun#2643. providers.<name>.transientRetryOn5xx opts a provider into retrying pre-stream transient statuses (500/502/503/504/520/521/522) across all three send paths: the initial Responses request, the terminal-guard continuation, and native /v1/chat/completions. Disabled unless present; a bare {} opts in with defaults. Scope is key-auth openai-chat only — the resolver checks the adapter explicitly rather than letting any generic key-auth provider opt in, and auth mode follows the same fail-closed rule as rateLimitRetryPolicyFor. The legacy direct-Google exception is preserved unchanged. attempts is a TOTAL send budget (1..10, default 3) covering both retry layers, so 3 means at most three real upstream requests. Both call sites extend the existing key-failover import, so no new module edge reaches responses/core.ts. * test(providers): cover the transient-retry resolver scope gates * docs: describe the transientRetryOn5xx provider option * fix(responses): keep 429-recovery refetches on the shared transient-retry budget Review finding on 2107f64: after an initial 429 the Responses recovery path enters rebuildAndRefetch, which called fetchWithHeaderTimeout directly. An opted-in provider's transient-5xx policy therefore applied to the initial send and to native chat but was silently bypassed on Responses recovery — a 429 that recovered into a retryable 503 got no retry at all. Every send now goes through the same selection. The budget is request-scoped rather than per-leg: fetchWithTransientRetry reports its consumed sends via onSendsConsumed (in a finally, since it returns from five places and throws from one), and the refetch receives only what is left. A request that recovers several times therefore cannot multiply upstream load. * fix(responses): hoist the transient-send budget to the shared request scope * test(upstream-retry): prove the shared budget survives across request legs * docs: localize the transientRetryOn5xx provider contract Review finding: the option was documented only in the English reference. All seven translated provider references now carry the same contract, including that attempts is one request-scoped total-send budget shared with connection-reset recovery and now also covering 429/account-recovery refetches.
- Add activeBatchRef with monotrophic batch id and AbortController - Abort stale batch before starting a new one - Abort batch on component unmount, apiBase change, and config change - Stale batch finally skips toast and busy-state cleanup - Button disabled during batch prevents concurrent clicks - Simplify provider-test.ts: remove verbose JSDoc, keep focused probe - Add 4 cancellation tests: unmount signal, no toast on abort, button disabled during batch, concurrency refill after completion
- Replace Set<string> with Map<string, number> for occurrence counts - ClearView records how many times each key appears in the buffer - Filtering consumes counts oldest-first so old duplicates hide before new - Cap counts to actual buffer occurrences to handle server eviction - Extract log-key.ts with minimal interface (LogKeyed + logKey) - Add 8 occurrence-aware tests: identical no-requestId clear, third duplicate visible, multiple duplicates, requestId vs fallback independence, buffer eviction cap, same-timestamp independence, filter isolation, resourceKey reset
The server assigns every log entry a unique requestId (ocx-),
guaranteed present on all entries returned by the management API. Drop the composite
fallback key that used timestamp/model/provider/status/durationMs, which caused new
entries sharing the same composite key as cleared entries to be incorrectly hidden
after server-side buffer eviction.
- Make LogEntry.requestId required (was optional)
- Simplify logKey to pass-through requestId (was LogKeyed -> string)
- Replace occurrence-count Map with simple Set of cleared requestIds
- Remove composite fallback, occurrence counting, and eviction capping
- Rewrite all occurrence-aware tests to use unique requestIds
- Allowlist logs.bufferCount ({shown} / {total}) and dash.port (Port) in FR and zh-TW locale tests
…ellation
Replace name-only configKey with a deterministic snapshot of provider entries
(name:adapter:baseUrl). This ensures the batch controller is aborted when a
provider's baseUrl or adapter changes even if the provider name stays the same.
- Replace Object.keys(config.providers).sort().join(',') with sorted
entry snapshot: name:adapter:baseUrl for each provider
- Add test 16: configSnapshot changes when provider baseUrl changes (same name)
- Add test 17: stale batch does not show toast, new batch completes with own results
- Add test 15: apiBase change aborts old batch signal
- Clean up dead requestId ?? fallback patterns in Logs.tsx (4 sites) since requestId is now a required LogEntry field - Remove no-requestId fixture from 'mixed' test, replace with entries that all have proper unique requestId values - Add root API regression test proving /api/logs returns entries with non-empty, unique, stable requestId strings
- Change activeBatchRef from dummy-initialized to nullable (null when no batch is in flight); cleaner ownership model - Extract providerTestInputSnapshot() to providers-shared.ts covering disabled, authMode, liveModels, adapter, baseUrl — all fields the test endpoint depends on - Test 15: uses root.render (same instance) to verify apiBase change aborts old batch, not unmount - Test 16: imports shared providerTestInputSnapshot, verifies all test-relevant config field changes produce different snapshots - Test 13: starts two real sequential batches, verifies both deferreds are consumed and button state transitions correctly
- Separate monotonic batch counter (nextBatchIdRef) from active ref to prevent batch ID reset when activeBatchRef is cleared - Replace abortActiveBatch() with cancelCurrentBatch() that also calls setBatchTesting(false), preventing perpetual Testing state after apiBase/config change or unmount - In testAllProviders finally block: clear activeBatchRef when active, not when stale; stale batches skip all UI updates silently - Add hasHeaders to providerTestInputSnapshot for completeness - Rewrite tests 13/17: 1 provider ensures deferred blocks entire batch; test 13 uses root.render for real overlapping replacement scenario
- Add requestId generation test proving nextRequestLogId() produces unique, format-compliant ocx- IDs (50 samples, collision-free) - Add stability test verifying same entry's requestId is preserved across two consecutive /api/logs reads - Add DTO passthrough test confirming requestLogDto() preserves the original requestId field unchanged - Add entries-level tests proving API returns non-empty, unique IDs
…bort upstream - Remove providerTestInputSnapshot() — incomplete field coverage made it unreliable as a batch-cancellation trigger - Add providerConfigGeneration state bumped by useProvidersFetch after every successful /api/config fetch - Replace configSnapshot effect with providerConfigGeneration effect - Split cancelCurrentBatch into: * cancelMountedBatch() — abort + setBatchTesting(false) for apiBase/config * abortBatchOnUnmount() — abort only, no setState, for unmount safety - Propagate GUI request signal to upstream probe via AbortSignal.any - Rewrite test 16: verifies generation bump through real component path (apiBase change → config refresh → generation increment → batch cancel)
- Expose test-only hook via __OCX_TEST_HOOKS for same-base config-refresh verification without changing apiBase - Rewrite test 16: fetchConfig is called through the production path, cfgB is genuinely returned by the second /api/config response, generation bump triggers cancelMountedBatch and aborts batch A - Add provider route upstream abort regression test: client AbortController signal propagates to outbound fetch via AbortSignal.any; abort reason is not reflected in error response - Fix catch block to swallow AbortError reasons (security: no leak) - Refactor Providers test hook to use mutable ref (no stale snapshot)
- Extract batch controller to gui/src/hooks/use-provider-batch-controller.ts with startBatch/cancelMountedBatch/isActiveBatch/abortBatchOnUnmount API - Remove global test hook (__OCX_TEST_HOOKS, providersBatch, testBatchState) from Providers.tsx; production bundle is clean - Simplify use-providers-fetch.ts: remove setProviderConfigGeneration since generation tracking is now internal to the batch controller hook - Hardened provider-routes.ts abort sanitization: * clientAborted check via req.signal.aborted covers DOMException and Error * timeout check via upstreamSignal.aborted && !clientAborted * Returns neutral 'Connection test aborted' / 'Connection test timed out' - Expand provider connection regression tests: * DOMException abort reason must not leak (existing) * Ordinary Error abort reason must not leak (new) * Use promise-based signal capture instead of setTimeout(50) - Rewrite test 16 as direct hook unit test (no global state needed)
…s stop calling tools (lidge-jun#3012) * fix(kiro): advertise the completion tool as terminal so finished turns stop calling tools The private completion tool is enumerated by the shared tool-catalog nudge next to ordinary tools, and that nudge tells every listed name to "count a tool call only after its tool result returns". Nothing returns a result for this one: a valid call becomes the turn's terminal. Nothing in either injected surface said so, so the model read one more deferrable work tool. Measured on a live 2.36.0 proxy: the completion tool was chosen in 25 of 4069 required-mode attempts. Across 1116 Kiro turns of client rollouts, 626 ended through the completion channel while 28 ended with answer-shaped commentary and no completion call at all - finished answers opening with "Done." or "완료", delivered as mid-task commentary, which by the proxy's own contract does not end the turn. Three of those are followed by 4, 10, and 12 further tool calls after the closing summary was already on screen. Both injected surfaces now carry the distinction: the schema description, which travels with the tool object the model is choosing between, and the prose contract, which must not contradict it. The mid-task rules are unchanged - commentary still does not end the turn and the model must still keep using tools before completing; only what may follow the completion call is constrained. Ruled out first: a replayed post-answer tool call (532 rollouts scanned, zero) and a broken delivered-answer local terminal (live closed-turn replays with and without an echoed phase both answered locally with zero upstream requests). Verified: bun run typecheck; 197 pass / 0 fail across tests/kiro-adapter.test.ts, tests/kiro-stream.test.ts, tests/tool-catalog-nudge.test.ts. The new regression test was driven red against the old description first. * docs(devlog): drop absolute home paths from the Kiro measurement table privacy:scan flags a remote absolute home path in a public devlog directory. The host identities that matter are the hostname, PID, and version, so the checkout column carries a neutral form instead.
…idge-jun#3014) Records the terminal outcome, the merge of lidge-jun#3012 as f5a625c, why the one CI failure is pre-existing on dev, and what was done with each review finding - including a truncation guard that was implemented, measured unreachable, and reverted rather than shipped with a test that could not detect its own removal. The follow-up is the post-change selection-rate comparison; the pre-change number is 25 completion calls across 4069 required-mode attempts.
…idge-jun#3013) * docs(devlog): plan the dev version-line bump PR after four audit rounds dev's package.json is 2.36.0 while tag v2.36.0 names c7d8407 on main, so tests/release-version-line.test.ts fails on dev and on every PR against it. The same defect has been repaired by hand four times (32529c2, e4a85d1, 076ad30, befcac3) because nothing in scripts/release.ts or release.yml advances dev after a publish. Records the cause, the rejected options, and the shipped design: a separate release-triggered workflow that opens a version-bump PR against dev, plus a pure decision script that imports compareReleaseTags from release-notes.ts so scripts/release.ts stays untouched. Three audit rounds failed this plan before it passed, and each FAIL changed the design rather than the prose: a printed notice was rejected because the existing test is already louder than a printout; the first workflow could not have run (release events resolve from the default branch) or imported its comparator (module-scope process.exit); and the +minor bump rule contradicted befcac3, which moved dev to 2.36.0 on a preview-first publish. All four verdicts are recorded in the unit. * fix(release): move dev's version line past the published 2.36.0 dev carried 2.36.0 while tag v2.36.0 names c7d8407 on main, so the tree claimed an already-published version from a different commit: release version line > the in-tree version is never behind a released one package.json version 2.36.0 equals release tag v2.36.0, but this commit is not the one that tag names. The tree claims an already-published version: publishing is refused as a duplicate. That failed test 2/4 and macos on dev itself (run 33312566315) and therefore on every PR opened against it, including lidge-jun#3007, whose own diff was two GUI files. 2.37.0 rather than 2.36.1 follows the precedent of all four prior repairs: dev carries the next stable version and the preview train adds its own suffix at release time. Freeness was verified live rather than assumed - no v2.37* tag, npm view @bitkyc08/opencodex@2.37.0 is E404, gh release view v2.37.0 is not found - and compareReleaseTags ranks v2.37.0 ahead of the highest tag v2.36.0. Note the highest tag is v2.36.0, not the later-dated v2.36.0-preview.20260830: sorting all 218 tags with the repository's own comparator puts a stable release above its own prerelease, which is why the failure message names v2.36.0. Verification: tests/release-version-line.test.ts goes 2 pass/1 fail -> 3 pass/0 fail. 260 pass / 0 fail across release-version-line, release-helper, release-notes, cli-version-skew, and service - the five suites that read package.json or assert on versions. test:changed selects nothing here because package.json is read as data, not imported, so those files were run explicitly. * feat(release): open the dev version bump as a PR when a release publishes dev's version line goes stale the moment a release publishes, because scripts/release.ts runs only on main/preview and release.yml ends at "Create GitHub release". Nothing advances dev, so release-version-line.test.ts fails on dev and on every PR opened against it. That was repaired by hand four times (32529c2, e4a85d1, 076ad30, befcac3). The second of those ADDED the detector and two more repairs followed it, so more visibility was never the missing piece. What this ships: - scripts/bump-dev-version.ts decides the version. Pure: no git, no network, so it is unit-testable and the credentials stay in the workflow. - .github/workflows/dev-version-bump.yml opens the PR on release: published. permissions {} at the top; the one job takes contents: write to push an unprotected codex/dev-version-* branch and pull-requests: write to open the PR. It never writes to dev and never uses the release deploy key, so release.yml and its review surface are untouched. The rule is not "increment the released minor" — that contradicts befcac3, which moved dev to 2.36.0 when v2.36.0-preview.20260829 published, because the stable 2.36.0 had not shipped. It keys off the published version's SHAPE: a prerelease of X.Y.Z means dev carries X.Y.Z; a stable X.Y.Z means dev moves to X.(Y+1).0. Freeness is not guessed either — the workflow runs release-version-line.test.ts against the rewritten tree and opens no PR if the candidate collides. Deliberate limits, stated rather than implied: a release event resolves the workflow from the DEFAULT branch, so this only fires once promoted to main; there is no workflow_dispatch, because a branch-selected manual run would execute that branch's body with contents: write; and a GITHUB_TOKEN PR does not start pull_request workflows, so the bump PR arrives without CI and a human merges it. This prepares the repair; it does not perform it. Verification: tests/bump-dev-version.test.ts 8 pass / 0 fail. Two real bugs were caught by those tests before commit — an ahead-check against the candidate instead of the released version, which would have downgraded a legitimate 2.37.0-preview.1 line, and a double "vv" prefix when handed the release tag_name the workflow actually passes. Each new rule was driven red: naive +minor fails 2 tests, the candidate-based guard fails 1, dropping the prefix normalisation fails 1. actionlint clean; every run block passes bash -n; the parsed YAML was asserted for permissions, trigger, and step list. * docs(devlog): record how the dev bump workflow differed from its plan Three deviations forced by the tree, not chosen: the composite setup-project-bun action instead of a hand-pinned setup-bun SHA, a local shape parse because parseReleaseTag is not exported, and a v-prefix normaliser because the workflow passes release.tag_name while package.json holds a bare version. Also records the ahead-check defect the tests caught: comparing dev against the candidate rather than the released version would have downgraded a legitimate 2.37.0-preview.1 line. * fix(release): check for an open bump PR, not just the branch A security review of the workflow found the idempotency guard incomplete. It checked only whether codex/dev-version-<v> existed as a branch, so an open bump PR whose head branch had been deleted left the check passing: the job would recreate the branch and then fail on gh pr create with "already exists", turning a successful release red for a repair that was already queued. Now checks for an open PR against dev first, then the branch. GH_TOKEN is already in scope for that step, so no new permission is needed. Also records the two residual gaps the review accepted rather than fixed: the GITHUB_OUTPUT write truncates rather than appends (equivalent today, not append-safe later), and no test exercises that output path. * fix(release): reuse an orphaned bump branch and write package.json atomically Two review findings from the maintainer on lidge-jun#3013. An existing branch was treated as terminal success: if a prior run pushed the branch and then failed at pull-request creation, every rerun exited 0 with no pull request, leaving the repair permanently unqueued. The job now fetches the branch, asserts it carries exactly the one-line package.json bump to the expected version, fails closed on anything else, and resumes pull-request creation. The rewrite used a direct write of package.json. scripts/AGENTS.md requires atomic replacement for package metadata, and this script is also the manual recovery path, so an interrupt mid-write would strand an uninstallable checkout. It now writes a sibling temp file, renames it into place, and removes the temp on failure. Two regressions cover it: no debris after a successful rewrite, and a byte-identical original when the write fails. * test(release): skip the unwritable-target case on Windows The read-only-directory test proves the atomic write fails closed, but chmod 0500 is not access control on Windows: the temp write would succeed there and the test would go red for a reason unrelated to the behavior under test. This file is a general suite member, so the Windows shards run it. Guarded with the same process.platform === win32 skip that tests/codex-native-residue.test.ts already uses for its EACCES case. The POSIX runners keep the coverage.
|
✅ Deterministic PR hygiene checks passed. |
⏳ DRAFT
What to do
This pull request is being kept as a draft automatically. Once every issue above is resolved, it will be marked ready for review again. |
randomix777
marked this pull request as ready for review
August 30, 2026 19:45
randomix777
marked this pull request as ready for review
August 30, 2026 19:57
Owner
Author
|
Closed - superseded by upstream PR lidge-jun#3025 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add manager-level UI on top of opencodex: web-based dashboard for monitoring providers, running batch connection tests, viewing logs with auto-scroll and clear-view, plus a Windows source-checkout launcher.
Changes
Dashboard & Providers
Logs
Launcher (Windows)
Tests
Diff
32 files changed, 3378 insertions(+), 51 deletions(-)
Notes