diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd02c3..a0bb814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Added a per-knob Stream Deck + Model Presets dial that immediately and continuously wraps through configurable model/reasoning pairs using Codex's native paired selection path. +- Added live model and reasoning dropdowns with add, remove, drag, accessible reorder controls, preserved unavailable entries, and an opt-in Ultra policy. +- Added `SWITCHING…` as non-optimistic pre-confirmation feedback, plus active-position and `UNLISTED` feedback derived only from confirmed state, along with empty, unavailable, and health feedback without keyboard or focus navigation. +- Added capability-gated Model Presets support for authenticated Mac/Windows relays; older peers fail closed without partial switching. +- Fixed standalone command keycaps such as Terminal after current Codex builds moved their guarded runner behind the live Micro bridge import. + ## 0.7.0.2 - 2026-07-22 - Normalize remote snapshot timestamps to local receipt time so ordinary Mac/Windows clock differences cannot hide working, selected, approval, or usage state. diff --git a/README.md b/README.md index 7ec5d7c..cd338f0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Windows-only and Mac-only mode have no relay, no second computer dependency, and - Native key-down/key-up handling for Micro slots `ACT06` through `ACT12`. - Native joystick up, right, down, left, and encoder click. - Dedicated reasoning-effort up/down buttons with press-and-hold repeat. +- Configurable Stream Deck + knobs with independent rotate, press, touch, and live feedback controls, including per-knob Model Presets that immediately switch among ordered model/reasoning pairs; see the [Stream Deck + guide](docs/STREAM_DECK_PLUS.md). - Live usage controls: a configurable circular 5-hour/weekly limit key and a two-window overview. - A centered reset-credit counter with a deliberate 1.2-second hold before an applicable credit can be consumed. - A local `codex://threads/new` action for a new task. @@ -43,9 +44,10 @@ Windows-only and Mac-only mode have no relay, no second computer dependency, and - Elgato Stream Deck 6.6 or newer on the computer connected to the Stream Deck. - Node.js 20 or newer for the platform launcher. - Windows 10+ or macOS 13+. -- Tested hardware: standard 15-key Stream Deck MK.2. +- Tested keypad hardware: standard 15-key Stream Deck MK.2. +- Stream Deck + is required for the Encoder-only **Codex Dial** action. -Other Stream Deck models may work, but the included layout and physical-device testing target the normal 5×3 MK.2. +Other keypad models may work, but the included 15-key layout and its existing physical-device testing target the normal 5×3 MK.2. Stream Deck + dial setup and its separate verification boundary are documented in the [Stream Deck + guide](docs/STREAM_DECK_PLUS.md). ## Quick install @@ -56,6 +58,7 @@ Other Stream Deck models may work, but the included layout and physical-device t 3. Follow [Windows](docs/WINDOWS.md), [macOS](docs/MACOS.md), or [Windows + Mac](docs/MULTI_HOST.md). 4. In **Codex Settings > Codex Micro**, choose the agent source, action assignments, joystick actions, and encoder behavior. 5. Build the two Stream Deck pages below. +6. On Stream Deck +, add the optional four-knob [Status-focused Codex Dial layout](docs/STREAM_DECK_PLUS.md#status-focused-four-knob-defaults). Its Model Presets knob uses live Codex model/reasoning choices, continuous wrap, Press None, and Fast Mode on touch by default. The iPhone companion is currently source-only: **a Mac with Xcode is required to build, sign, and install it**, even when the phone will control only a diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c3f0edb..2cd6e2c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,7 +54,8 @@ The same plugin runs on Windows and macOS. It discovers the local loopback port 4. dispatch Micro HID and joystick events; 5. emulate native encoder-rotation HID events for reasoning-effort changes; 6. resolve standalone keycap actions from Codex's live Micro keycap registry and current official command runner; -7. read Codex's renderer-owned `rate-limit-status` query and normalize its current 5-hour, weekly, and reset-credit state. +7. read Codex's renderer-owned `rate-limit-status` query and normalize its current 5-hour, weekly, and reset-credit state; +8. best-effort read the active visible composer's optional reasoning effort for honest Encoder feedback. The bridge does not emulate a USB HID device and installs no driver. @@ -62,6 +63,10 @@ Usage data remains part of the same typed host snapshot, but usage and reset cre Reset consumption is the only mutating usage operation. It is a narrow typed relay command and calls Codex's current native reset-credit client only after the Stream Deck key has been held for 1.2 seconds. The bridge verifies both availability and applicability, selects an available plan-supported credit, uses a unique redemption request ID, and then refreshes the renderer query. No credential, raw endpoint access, or arbitrary request surface is exposed to the relay. +`Codex Dial` is one Encoder-only Stream Deck action with versioned, per-instance settings. Its pure dial domain normalizes preset and custom settings against allow-listed bindings, expands each paired-rotation detent into an ordered command, reconciles selector identity, and derives the five stable feedback fields. The action adapter forwards Encoder rotate, down, up, and touch events to the controller; it does not add a second command surface. Rotation can dispatch paired controls immediately or update a selector preview, while press and touch remain independent. + +An explicit usage refresh is a typed operation. Locally, one renderer evaluation requests a fresh `rate-limit-status` query and rejects rather than replacing last-known usage when the forced refresh cannot return valid data. Remotely, the relay advertises `usage-refresh`, binds capability and snapshot state to the current authenticated connection generation, publishes a fresh post-command snapshot, and only then acknowledges success. Older peers may omit the optional capability and reasoning field. + ### Optional multi-host relay The Mac watcher can host an authenticated WebSocket relay on loopback behind an @@ -147,6 +152,14 @@ The controller uses non-overlapping self-scheduled refreshes and caches the last image sent to each action instance. Unchanged keys therefore produce no repeated USB image writes. Animated frames are limited to working and approval states. +Encoder feedback uses a custom 200 x 100 layout with keyed title, value, detail, +indicator, and accent fields. The controller keeps selector and overview state, +an async command queue, and the last normalized feedback signature per visible +dial action. `setFeedback` is skipped when that signature is unchanged. Dial +registration, settings replacement, disappearance, and in-flight render work +are generation-aware so an obsolete action instance cannot overwrite its +replacement. See the [Stream Deck + guide](STREAM_DECK_PLUS.md) for the user-facing behavior. + ## Trust boundary CDP provides privileged access to the Codex renderer. Binding to `127.0.0.1` prevents direct access from another machine, but not from another process running as the same local user. Treat the launcher-started session like any other local debugging session: diff --git a/docs/MACOS.md b/docs/MACOS.md index c71f743..37246b1 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -39,6 +39,10 @@ Tailscale remote access remains a separate optional profile; see **Start Codex Deck.command** is the double-clickable equivalent of `start`. 5. Open **Codex Settings > Codex Micro**, configure the native slots, and add the actions from the [recommended layout](../README.md#recommended-15-key-layout). Leave the Windows/Mac target position empty or replace it with another action. +### Stream Deck + knobs + +On a Stream Deck +, drag one **Codex Dial** action onto each knob you want to use. The recommended Model Presets, Agents, Actions, and Usage presets, independent rotate/press/touch settings, and the macOS physical verification checklist are in the [Stream Deck + guide](STREAM_DECK_PLUS.md). The guide's checklist is the verification boundary; this setup page does not by itself claim that a specific build completed hardware QA. + If an archive tool removed executable permissions, restore only the two launcher files: ```zsh diff --git a/docs/STREAM_DECK_PLUS.md b/docs/STREAM_DECK_PLUS.md new file mode 100644 index 0000000..7c7681e --- /dev/null +++ b/docs/STREAM_DECK_PLUS.md @@ -0,0 +1,151 @@ +# Stream Deck + Codex Dial + +`Codex Dial` is an Encoder-only action for Stream Deck +. Each knob stores its own preset, rotation, press, touch, and feedback settings, so changing one dial does not change the others. Existing Codex Deck keypad actions continue to work alongside it. + +## Requirements and setup + +- Codex Deck installed and connected to a bridge by following the [macOS](MACOS.md), [Windows](WINDOWS.md), or [multi-host](MULTI_HOST.md) guide. +- Stream Deck 6.6 or newer. +- A Stream Deck + for the four Encoder positions and touch strip. + +In the Stream Deck app, open the Codex Deck category and drag **Codex Dial** onto a knob. Select the new dial to open its property inspector, then choose a preset. Repeat for each knob you want to configure. Presets are starting points; each action instance can then be customized independently. + +## Status-focused four-knob defaults + +The recommended layout uses four separate `Codex Dial` instances: + +| Knob | Rotate | Press | Touch | Feedback | +|---|---|---|---|---| +| Model Presets | Immediately apply the previous or next configured model/reasoning pair, with continuous wrap | None | Fast Mode | Confirmed model, reasoning effort, and valid-list position | +| Agents | Select the previous or next occupied agent | Focus the highlighted agent | Tasks view | Agent title, status, context use, and M/W host badge when relevant | +| Actions | Select Fast, Approve, Reject, Fork, Dictation, or Send in that order | Run the highlighted action | Settings | Selected action and activation hint | +| Usage | Select Auto, 5h, or Weekly | Toggle the selected single-window view and two-window overview | Refresh usage now | Remaining capacity, reset time, health, and view mode | + +The Fast key uses authoritative composer state: green means Fast Mode is enabled, red means it is disabled, and the normal neutral surface means the live state is unknown or unavailable. + +Reasoning changes apply once per detent through Codex's dedicated increase and decrease commands. Rotation does not move keyboard focus through composer controls and does not require another press or a user confirmation. + +The Reasoning knob redraws as soon as Codex confirms the resulting level after a short, bounded confirmation. The normal 1.2-second background poll remains a reconciliation path, not the primary feedback path. If the command fails or confirmation is missing, the dial retains the last authoritative level instead of predicting a new one. A paired host gets this prompt redraw only when the connected peer supports and returns confirmed reasoning feedback. + +**Include Ultra** is configured per knob and defaults off. When Include Ultra is off, clockwise reasoning stops before Ultra and the dial briefly shows `ULTRA OFF` without sending a reasoning command. When Include Ultra is on, the knob can enter Ultra and Codex may show its native Full-access confirmation. Manual Codex selection and the keypad Reasoning Up action stay unrestricted. The plugin never confirms or dismisses that native dialog. + +The six action names are the default slot labels for the six configured Codex Micro action slots. Feedback labels follow the current Codex Micro assignments, so a customized slot is identified by its current assignment rather than a hardcoded default name. + +## Gesture behavior + +### Model Presets + +Model Presets immediately applies one pair per detent and uses continuous wrap in both directions. Clockwise chooses the next currently valid configured pair and counter-clockwise chooses the previous one. If Codex's active pair is not in the list, clockwise starts at the first valid pair and counter-clockwise starts at the last. Rapid turns are queued in arrival order, and each detent is resolved from the pair that Codex confirmed for the preceding detent. + +The preferred starting list is `5.6 Sol · High`, `5.6 Sol · Medium`, and `5.6 Terra · Medium`, when the current Codex catalog offers those exact pairs. Its default gestures are Press **None** and Touch **Fast Mode**; both remain independently customizable per knob. + +The property inspector supplies live model and reasoning dropdowns for each row per knob. You can add, remove, drag, or use accessible move buttons to reorder entries. Saved entries that the current catalog cannot use remain visible: unavailable entries are preserved and skipped without being deleted. Additions and catalog-dependent edits pause while authority is unavailable. + +**Include Ultra** for Model Presets is per knob and defaults off. With it off, the editor does not offer new Ultra pairs and rotation skips a saved Ultra entry; turning it back on makes a saved entry eligible again if Codex still advertises it. The plugin never approves or dismisses Codex's native Ultra dialog. + +Model Presets does not use keyboard events or focus navigation. It calls Codex's native paired model/reasoning selection path once, and then waits for the exact resulting pair to be reported. + +Feedback is deliberately non-optimistic: `SWITCHING…` appears before the pair is confirmed, without naming the requested pair as active. A confirmed pair shows its current display name, reasoning level, and position such as `2 / 3`. `UNLISTED` shows the actual pair Codex reported when it is not among the valid configured entries. `NO PRESETS` means the saved list is empty or the authoritative catalog proves that no saved entry is currently valid. `UNAVAILABLE` means the authoritative catalog or active pair cannot currently be obtained, so rotation sends nothing. + +### Paired controls + +In **Paired controls** mode, counter-clockwise and clockwise rotation have separate bindings. Every detent runs its binding in order. This mode is useful for reasoning decrease/increase and navigation back/forward. Reaching a supported boundary is a harmless no-op. + +### Rotate to select + +In **Rotate to select** mode, rotation only highlights an item; it does not run it. Press **Activate Selection** to run the highlighted agent or configured action. The selector can wrap at either end, and an Actions selector preserves the order chosen in the property inspector. The Usage selector cycles Auto, 5h, and Weekly. + +The Actions selector is local and immediate: each rotation redraws the highlighted action without waiting for Codex, while activation still waits for a press. + +An empty selector shows `NO ITEMS` and cannot be activated. Pressing it produces the standard Stream Deck alert without dispatching an action. + +### Press and touch + +**Press** and **Touch tap** are independent. Press preserves the selected command's lifecycle: Micro and joystick controls send their down/up pair, while reasoning, keycap, new-task, host, and usage commands run once. A touch tap dispatches one complete action lifecycle and can also be set to **None**. + +Holding the dial while rotating does not reveal a second binding layer. A held touch event is ignored. + +## Property inspector reference + +| Field | Purpose | +|---|---| +| Preset | Choose Model Presets, Reasoning, Agents, Actions, Navigation, Usage, or Custom defaults. Choosing a preset replaces all fields for that dial. | +| Model preset list | For Model Presets, add up to twelve unique model/reasoning pairs, remove rows, and reorder them by drag or accessible move buttons. | +| Rotation mode | Choose Paired controls or Rotate to select. | +| Counter-clockwise / Clockwise | Choose the two bindings used by paired rotation. | +| Selector source | Choose occupied agents, configured actions, or usage windows. | +| Wrap at ends | Allow the selector to continue from the last item to the first and vice versa. | +| Configured actions | Enable and reorder the allow-listed actions available to an Actions selector. | +| Include Ultra | Allow this knob's Reasoning control or Model Presets list to use Ultra. It is off by default. | +| Press | Choose an independent dial-down/dial-up action or Activate Selection. | +| Touch tap | Choose an independent tap action. | +| Feedback | Choose Automatic, Current reasoning, Selected agent, Selected action, Navigation pair, Usage window / overview, or Static label. | +| Static label | Set the text used by Static label feedback. | + +Changing an individual field marks only that dial as customized. Settings contain allow-listed action identifiers rather than arbitrary commands, URLs, or scripts. + +### Rate-limit reset protection + +**Reset rate-limit credit (hold)** is available only for Press. It is intentionally absent from rotation, touch, and selector item choices. A reset requires a deliberate 1.2-second hold and Codex must report that a credit is both available and applicable. A short press does nothing. Successful use temporarily shows `RESET COMPLETE` with green feedback before returning to live status. + +## Feedback and routing + +Current reasoning is read from Codex's live composer state, not inferred from the last turn of the knob. If that value is not confidently available in the current Codex build, the dial honestly shows `REASONING · UNAVAILABLE`. + +Usage reset detail uses compact day/hour/minute units, such as `RESETS IN 5D 5H 48M`. The same formatter applies to Auto, 5-hour, and Weekly modes. + +In single-host mode, controls and feedback use the local Codex instance. In multi-host mode: + +- Agent selection and focus stay bound to the highlighted task's owning host and stable task identity. Agent feedback includes an M/W host badge when the merged list needs to distinguish the owner. +- Reasoning, navigation, Micro, keycap, new-task, and host controls use the selected function-control host. +- Model Presets also uses the selected function-control host. A remote host must advertise the `model-presets` capability; an older or reconnecting peer is refused before any command is sent rather than receiving a partial model-only or reasoning-only fallback. +- Usage is account-scoped. It prefers a healthy local usage snapshot. When healthy local usage is unavailable, it selects a healthy paired host with usable account data. +- New command starts require a healthy, applicable route. A cleanup release for a momentary action that already went down may still be attempted against its captured route so the remote control is not left pressed. + +Feedback may retain last-known information while a host is degraded or offline, but that information is display-only and cannot start a new command. + +## macOS physical verification checklist + +After installing a development build on macOS, back up the installed plugin and the affected Stream Deck + profile before replacing anything. Then verify: + +- all four Status-focused presets can be added without changing existing keypad actions; +- Model Presets seeds the available preferred pairs `5.6 Sol · High`, `5.6 Sol · Medium`, and `5.6 Terra · Medium`; +- both Model Presets wrap directions apply immediately, rapid detents stay ordered, and the LCD never claims a pair before Codex confirms it; +- Fast Mode works from the default Model Presets touch action, while Press remains None unless customized; +- an unavailable saved entry remains in the editor, is skipped during rotation, and becomes eligible again when its catalog pair returns; +- each counter-clockwise and clockwise detent is preserved, including a fast turn; +- selector rotation previews without running anything until press; +- press down/up and each touch region act independently; +- reasoning and usage feedback follows changes made inside Codex; +- disconnected, reconnecting, and degraded feedback is honest; +- settings persist after restarting Stream Deck and Codex. + +Record the exact macOS, Codex, Stream Deck, and hardware versions when this checklist is completed. This checklist describes the required physical QA; its presence is not a claim that a particular development build has already completed it. + +## Windows compatibility boundary + +The plugin source, manifest, property inspector, and packaged Encoder assets use the same cross-platform Stream Deck SDK path on Windows and macOS. Windows build and CI coverage is not physical-device verification. Do not report Stream Deck + hardware coverage on Windows until the same behavior checklist has been performed on a real device and the exact environment has been recorded. + +## Troubleshooting + +### `NO ITEMS`, `NO PRESETS`, or `UNAVAILABLE` + +`NO ITEMS` means the current selector is empty. For Agents, open Codex and make sure the selected Codex Micro source has at least one occupied task. For Actions, select at least one configured action in the property inspector. `NO PRESETS` means a Model Presets list is empty or has no entries supported by the current authoritative catalog; open that knob's property inspector to add or inspect its saved rows. `UNAVAILABLE` applies to live Usage or Reasoning values that the current Codex build did not report, or to Model Presets when the authoritative catalog and active pair are not available. Saved preset rows are retained while the host is offline or reconnecting. + +If a paired host works for other controls but Model Presets remains unavailable, update Codex Deck on that peer and reconnect it. The feature deliberately requires its advertised relay capability and has no mixed-version fallback. + +### `CONNECTING` + +The plugin is waiting for a fresh bridge or relay snapshot. Confirm that Codex is open through the platform launcher and let the guarded watcher reconnect. Avoid repeatedly restarting Codex. + +### `DEGRADED` + +The last snapshot is retained for display, but live state is uncertain or stale. Check the platform launcher diagnostics and, in multi-host mode, the authenticated relay connection. New command starts remain blocked when the captured route is not healthy; a cleanup release may still be attempted for a momentary command that already started. + +### `OFFLINE` + +No live route is available for the selected host. Start or repair the local bridge, or restore the paired relay connection. The debug endpoint must remain loopback-only; do not expose or forward it. + +### Stale feedback + +Make a small Codex-side state change and wait for the normal refresh. Usage can be refreshed immediately with the configured touch action. If feedback remains stale, reload only the Codex Deck plugin or Stream Deck app, then run the platform's read-only diagnostics. Recheck the dial's Feedback field if it intentionally uses a fixed mode or Static label. diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index b663c48..12d12dd 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -39,6 +39,10 @@ Tailscale remote access remains available as a separate profile; see 5. Double-click **Start Codex Deck.cmd**. A bridge-enabled Codex session is reused. If Codex is already running normally without the bridge, the launcher explains that one restart is required before doing it. 6. Open **Codex Settings > Codex Micro**, configure the native slots, and add the actions from the [recommended layout](../README.md#recommended-15-key-layout). +### Stream Deck + knobs + +On a Stream Deck +, drag one **Codex Dial** action onto each knob you want to use. See the [Stream Deck + guide](STREAM_DECK_PLUS.md) for the recommended Reasoning, Agents, Actions, and Usage presets and the independent rotate/press/touch controls. Windows build and CI compatibility is not a claim of physical Stream Deck + verification; report hardware coverage only after completing the guide's behavior checklist on a real device and recording the exact environment. + ## Keep the bridge available Run once from the extracted launcher folder: diff --git a/docs/superpowers/plans/2026-08-09-fast-mode-reasoning-controls-fix.md b/docs/superpowers/plans/2026-08-09-fast-mode-reasoning-controls-fix.md new file mode 100644 index 0000000..5013897 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-fast-mode-reasoning-controls-fix.md @@ -0,0 +1,535 @@ +# Fast Mode Feedback and Reasoning Dial Correction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Stream Deck FAST keys show authoritative green/red state and make every reasoning detent invoke Codex's dedicated reasoning commands instead of composer focus navigation. + +**Architecture:** Extend the existing renderer-owned `MicroSnapshot` with an optional strict Fast boolean, preserve it through the relay trust boundary, and feed it only into FAST key rendering. Keep commands authoritative: successful local Fast commands force a fresh snapshot, remote commands retain the relay's post-command barrier, and reasoning adjustment delegates to the allow-listed official `MIND+` / `MIND-` keycap command path. + +**Tech Stack:** TypeScript, Node.js test runner through `tsx --test`, Elgato Stream Deck SDK, CDP renderer bridge, SVG data-URL rendering, WebSocket relay. + +--- + +## File Map + +- `src/types.ts` — add the optional snapshot Fast state. +- `src/codex-micro-renderer-bridge.ts` — read visible composer Fast state and route reasoning through official keycap commands. +- `src/relay-protocol.ts` — validate optional Fast state as a literal boolean. +- `src/render.ts` — apply green/red/neutral state backgrounds to imported and fallback keycaps. +- `src/controller.ts` — pass Fast state to FAST renderers and refresh after successful local Fast activation. +- `test/micro-bridge.test.ts` — renderer-state and reasoning-command regressions. +- `test/relay.test.ts` — relay snapshot Fast-state trust-boundary regressions. +- `test/render-theme.test.ts` — exact green/red/neutral keycap rendering regressions. +- `test/controller-dial.test.ts` — controller rendering and forced-refresh behavior. +- `docs/STREAM_DECK_PLUS.md` — document Fast feedback and dedicated reasoning behavior. + +## Task 1: Carry Authoritative Fast State Through Snapshots + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/codex-micro-renderer-bridge.ts` +- Modify: `src/relay-protocol.ts` +- Test: `test/micro-bridge.test.ts` +- Test: `test/relay.test.ts` + +- [ ] **Step 1: Write the failing visible-composer state test** + +Add a focused test beside `renderer snapshots require one unique visible semantic reasoning target` in `test/micro-bridge.test.ts`: + +```ts +test("renderer snapshots distinguish verified Fast on, Fast off, and unavailable state", () => { + const reader = Reflect.get(microBridgeModule, "readActiveFastMode") as ( + elements: Array<{ semantic: boolean; visible: boolean; fast: boolean }>, + isVisible: (element: { visible: boolean }) => boolean, + hasFastIndicator: (element: { fast: boolean }) => boolean + ) => boolean | undefined; + const element = (semantic: boolean, visible: boolean, fast: boolean) => ({ + semantic, visible, fast, + getAttribute(name: string) { + return name === "data-composer-navigation-target" && semantic ? "reasoning" : null; + } + }); + + assert.equal(reader([element(true, true, true)], (item) => item.visible, (item) => item.fast), true); + assert.equal(reader([element(true, true, false)], (item) => item.visible, (item) => item.fast), false); + assert.equal(reader([element(true, false, true)], (item) => item.visible, (item) => item.fast), undefined); + assert.equal(reader([element(false, true, true)], (item) => item.visible, (item) => item.fast), undefined); + assert.equal(reader( + [element(true, true, true), element(true, true, false)], + (item) => item.visible, + (item) => item.fast + ), undefined); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +npx tsx --test --test-name-pattern='renderer snapshots distinguish verified Fast' test/micro-bridge.test.ts +``` + +Expected: FAIL because `readActiveFastMode` is not exported. + +- [ ] **Step 3: Implement the minimal visible-state reader and snapshot field** + +In `src/types.ts`, add: + +```ts +/** Whether the active visible composer has Fast service tier enabled. */ +fastModeEnabled?: boolean; +``` + +to `MicroSnapshot` beside `reasoningEffort`. + +In `src/codex-micro-renderer-bridge.ts`, reuse the reasoning element visibility contract and add: + +```ts +export function readActiveFastMode( + elements: Iterable, + isVisible = defaultReasoningElementVisibility, + hasFastIndicator = (element: ReasoningEffortElement): boolean => + Boolean((element as unknown as Element).querySelector( + 'svg[class*="ModelPickerTriggerInlineFastIcon"]' + )) +): boolean | undefined { + const candidates = new Set(); + for (const element of elements) { + if (!isVisible(element)) continue; + if (element.getAttribute("data-composer-navigation-target") !== "reasoning") continue; + candidates.add(hasFastIndicator(element)); + } + return candidates.size === 1 ? candidates.values().next().value : undefined; +} +``` + +Extract the existing default visibility callback into `defaultReasoningElementVisibility` so both readers share it. Because the reader functions are serialized into the renderer expression, inject the helper before both readers as well: + +```ts +const defaultReasoningElementVisibility = (${defaultReasoningElementVisibility.toString()}); +const readActiveReasoningEffort = (${readActiveReasoningEffort.toString()}); +const readActiveFastMode = (${readActiveFastMode.toString()}); +``` + +Then query the same verified reasoning triggers and include the field only when it is a boolean: + +```ts +const reasoningTargets = document.querySelectorAll( + '[data-codex-intelligence-trigger="true"][data-composer-navigation-target="reasoning"]' +); +const reasoningEffort = readActiveReasoningEffort(reasoningTargets); +const fastModeEnabled = readActiveFastMode(reasoningTargets); + +return { + slots, activeThreadKey, activeThreadTitle, layout, agentSource, lightingAutoOff, theme, + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(typeof fastModeEnabled === 'boolean' ? { fastModeEnabled } : {}), + ...(usage ? { usage } : {}) +}; +``` + +- [ ] **Step 4: Run the focused bridge tests and verify GREEN** + +Run: + +```bash +npx tsx --test --test-name-pattern='visible semantic reasoning|verified Fast' test/micro-bridge.test.ts +``` + +Expected: 2 matching tests pass. + +- [ ] **Step 5: Write the failing relay validation test** + +Extend the optional-reasoning snapshot test in `test/relay.test.ts`: + +```ts +test("relay snapshots accept only an optional literal Fast boolean", () => { + const message = { type: "snapshot", protocol: 1, host, observedAt: 1, snapshot: structuredClone(snapshot) }; + message.snapshot.fastModeEnabled = true; + assert.notEqual(parseRelayServerMessage(message), null); + message.snapshot.fastModeEnabled = false; + assert.notEqual(parseRelayServerMessage(message), null); + for (const malformed of [null, 0, 1, "true", [], {}]) { + message.snapshot.fastModeEnabled = malformed as never; + assert.equal(parseRelayServerMessage(message), null); + } +}); +``` + +- [ ] **Step 6: Run the relay test and verify RED** + +Run: + +```bash +npx tsx --test --test-name-pattern='literal Fast boolean' test/relay.test.ts +``` + +Expected: FAIL because malformed Fast fields are currently accepted. + +- [ ] **Step 7: Validate Fast state at the relay trust boundary** + +In `isSnapshot()` in `src/relay-protocol.ts`, add: + +```ts +if (value.fastModeEnabled !== undefined && typeof value.fastModeEnabled !== "boolean") return false; +``` + +- [ ] **Step 8: Run focused and full slice checks** + +Run: + +```bash +npx tsx --test test/micro-bridge.test.ts test/relay.test.ts +npm run check +git diff --check +``` + +Expected: all focused tests and type-check pass; diff check is silent. + +- [ ] **Step 9: Commit the snapshot slice** + +```bash +git add src/types.ts src/codex-micro-renderer-bridge.ts src/relay-protocol.ts test/micro-bridge.test.ts test/relay.test.ts +git commit -m "feat: expose authoritative Fast mode state" +``` + +## Task 2: Render and Refresh Fast State + +**Files:** +- Modify: `src/render.ts` +- Modify: `src/controller.ts` +- Test: `test/render-theme.test.ts` +- Test: `test/controller-dial.test.ts` + +- [ ] **Step 1: Write failing green/red/neutral renderer tests** + +Add to `test/render-theme.test.ts`: + +```ts +test("Fast key backgrounds are green on, red off, and neutral when unknown", () => { + const icon = ''; + const decode = (value: string) => decodeURIComponent( + value.replace(/^data:image\/svg\+xml;charset=utf8,/, "") + ); + const enabled = decode(renderImportedKeycap(icon, "dark", true)); + const disabled = decode(renderImportedKeycap(icon, "dark", false)); + const unknown = decode(renderImportedKeycap(icon, "dark")); + + assert.match(enabled, /data-toggle-state="on"/); + assert.match(enabled, new RegExp(SIGNAL_COLORS.dark.complete, "i")); + assert.match(disabled, /data-toggle-state="off"/); + assert.match(disabled, new RegExp(SIGNAL_COLORS.dark.error, "i")); + assert.match(unknown, /data-toggle-state="unknown"/); + assert.doesNotMatch(unknown, /data-toggle-background=/); +}); +``` + +Add the same state assertions for `renderFallbackKeycap("FAST", "dark", state)` so missing user artwork does not lose feedback. + +- [ ] **Step 2: Run the render test and verify RED** + +Run: + +```bash +npx tsx --test --test-name-pattern='Fast key backgrounds' test/render-theme.test.ts +``` + +Expected: FAIL because the renderers do not accept or expose toggle state. + +- [ ] **Step 3: Implement stateful keycap backgrounds** + +Change both renderer signatures: + +```ts +export function renderImportedKeycap( + svg: string, + theme: ThemeMode = "light", + toggleState?: boolean +): string + +export function renderFallbackKeycap( + keycapId: string, + theme: ThemeMode = "light", + toggleState?: boolean +): string +``` + +For each renderer, derive: + +```ts +const toggleColor = toggleState === true + ? SIGNAL_COLORS[theme].complete + : toggleState === false + ? SIGNAL_COLORS[theme].error + : undefined; +const toggleLabel = toggleState === true ? "on" : toggleState === false ? "off" : "unknown"; +``` + +Mark the outer key and add a strong state wash only for known state: + +```ts + +${toggleColor ? `` : ""} +``` + +Keep the glyph above the wash and retain the existing borders. + +- [ ] **Step 4: Run renderer tests and verify GREEN** + +Run: + +```bash +npx tsx --test test/render-theme.test.ts +``` + +Expected: all rendering tests pass. + +- [ ] **Step 5: Write failing controller rendering and refresh tests** + +Add focused controller tests that install a local snapshot whose `ACT06` keycap is `FAST` and register both a Micro action and a fixed `FAST` key. Assert their images decode to `data-toggle-state="on"` for `true`, `off` for `false`, and `unknown` when omitted. + +Add a command test with a bridge spy: + +```ts +test("successful local Fast activation refreshes once without refreshing release", async () => { + const controller = new DeckController(); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = hostSnapshot({ ...snapshot, fastModeEnabled: false }); + const events: Array<[string, 0 | 1]> = []; + let refreshes = 0; + state.microBridge = { + async sendAction(slot, act) { events.push([slot, act]); }, + async refresh() { refreshes += 1; return { ...snapshot, fastModeEnabled: true }; } + }; + + await controller.sendMicroAction("ACT06", 1); + await controller.sendMicroAction("ACT06", 0); + + assert.deepEqual(events, [["ACT06", 1], ["ACT06", 0]]); + assert.equal(refreshes, 1); +}); +``` + +Also assert a rejected Fast activation leaves `refreshes === 0` and the last snapshot unchanged. + +- [ ] **Step 6: Run controller tests and verify RED** + +Run: + +```bash +npx tsx --test --test-name-pattern='Fast activation|FAST key state' test/controller-dial.test.ts +``` + +Expected: FAIL because controller rendering is neutral and activation does not refresh. + +- [ ] **Step 7: Pass Fast state through the keycap cache and renderers** + +Change `keycapImage` to accept `toggleState?: boolean`, include `on`, `off`, or `unknown` in its cache key, and pass the state to both imported and fallback renderers. + +In `renderMicroAction`, derive state only for `keycapId === "FAST"`: + +```ts +const toggleState = keycapId === "FAST" ? snapshot?.fastModeEnabled : undefined; +const image = await this.keycapImage(keycapId, snapshot?.theme ?? "dark", toggleState); +``` + +In `renderFixedAction`, pass the same state only when `registration.source.kind === "local"` and its keycap ID is `FAST`. All other keycaps remain neutral. + +- [ ] **Step 8: Refresh only after successful local Fast activation** + +Add private local command helpers in `src/controller.ts`: + +```ts +private async sendLocalMicroAction(slot: MicroActionSlot, act: 0 | 1): Promise { + const isFastActivation = act === 1 && + this.localSnapshot?.snapshot.layout.slots[slot]?.keycapId === "FAST"; + await this.microBridge.sendAction(slot, act); + if (isFastActivation) await this.refresh(); +} + +private async runLocalKeycap(keycapId: OfficialKeycapId): Promise { + await this.microBridge.runKeycap(keycapId); + if (keycapId === "FAST") await this.refresh(); +} +``` + +Use `sendLocalMicroAction` from `sendMicroAction` and `runLocalKeycap` from `runKeycap` and the dial `keycap.*` local callback. Do not refresh after remote commands because the relay already publishes a fresh post-command snapshot before acknowledging success. + +- [ ] **Step 9: Run focused and full slice checks** + +Run: + +```bash +npx tsx --test test/render-theme.test.ts test/controller-dial.test.ts +npm run check +git diff --check +``` + +Expected: focused tests and type-check pass; diff check is silent. + +- [ ] **Step 10: Commit the feedback slice** + +```bash +git add src/render.ts src/controller.ts test/render-theme.test.ts test/controller-dial.test.ts +git commit -m "fix: show live Fast mode feedback" +``` + +## Task 3: Route Reasoning Through Dedicated Commands + +**Files:** +- Modify: `src/codex-micro-renderer-bridge.ts` +- Test: `test/micro-bridge.test.ts` +- Test: `test/controller-dial.test.ts` + +- [ ] **Step 1: Write the failing bridge command-routing test** + +Add to `test/micro-bridge.test.ts`: + +```ts +test("reasoning adjustment delegates to official commands instead of raw encoder navigation", async () => { + const bridge = new microBridgeModule.CodexMicroRendererBridge(() => {}); + const keycaps: string[] = []; + let rawDispatches = 0; + const probe = bridge as unknown as { + runKeycap: (id: string) => Promise; + dispatch: () => Promise; + }; + probe.runKeycap = async (id) => { keycaps.push(id); }; + probe.dispatch = async () => { rawDispatches += 1; }; + + await bridge.adjustReasoning("increase"); + await bridge.adjustReasoning("decrease"); + + assert.deepEqual(keycaps, ["MIND+", "MIND-"]); + assert.equal(rawDispatches, 0); +}); +``` + +- [ ] **Step 2: Run the bridge test and verify RED** + +Run: + +```bash +npx tsx --test --test-name-pattern='official commands instead of raw encoder' test/micro-bridge.test.ts +``` + +Expected: FAIL because `adjustReasoning` calls `dispatch` with `ENC_CC` / `ENC_CW`. + +- [ ] **Step 3: Replace raw reasoning encoder events** + +Remove `REASONING_ENCODER_KEYS` and implement: + +```ts +async adjustReasoning(direction: ReasoningAdjustment): Promise { + await this.runKeycap(direction === "increase" ? "MIND+" : "MIND-"); +} +``` + +Leave `sendEncoder(act)` unchanged for the separate legacy encoder-click action. + +- [ ] **Step 4: Add one-detent controller regression** + +Extend the paired reasoning dial test in `test/controller-dial.test.ts` to rotate one tick in each direction and assert exactly one `adjustReasoning("increase")` and one `adjustReasoning("decrease")`, with no `sendEncoder` calls. + +- [ ] **Step 5: Run focused and full slice checks** + +Run: + +```bash +npx tsx --test test/micro-bridge.test.ts test/controller-dial.test.ts +npm run check +git diff --check +``` + +Expected: focused tests and type-check pass; diff check is silent. + +- [ ] **Step 6: Commit the reasoning slice** + +```bash +git add src/codex-micro-renderer-bridge.ts test/micro-bridge.test.ts test/controller-dial.test.ts +git commit -m "fix: use dedicated reasoning commands" +``` + +## Task 4: Documentation, Release Gates, and Installation + +**Files:** +- Modify: `docs/STREAM_DECK_PLUS.md` +- Test: all project tests and release gates +- Install: user-local Stream Deck plugin and existing Plus profile + +- [ ] **Step 1: Document the corrected behavior** + +Update the status-focused defaults section in `docs/STREAM_DECK_PLUS.md` to state: + +```md +The Fast key uses authoritative composer state: green means Fast Mode is enabled, red means it is disabled, and the normal neutral surface means the live state is unavailable. Reasoning rotation invokes Codex's dedicated increase/decrease commands; it does not move keyboard focus through composer controls. +``` + +- [ ] **Step 2: Run full verification** + +Run: + +```bash +npm run check +npm test +npm run validate +npm run audit:release +git diff --check +``` + +Expected: type-check passes; 241 existing tests plus the new regressions pass with only the existing Windows-specific skip; Stream Deck validation and all three release roots pass. + +Restore only the two known build-regenerated files if validation changes them: + +```bash +git restore --source=HEAD -- static/imgs/plugin-icon.png static/imgs/plugin-icon@2x.png +``` + +- [ ] **Step 3: Commit documentation** + +```bash +git add docs/STREAM_DECK_PLUS.md +git commit -m "docs: explain Fast and reasoning feedback" +``` + +- [ ] **Step 4: Obtain independent final review** + +Give the reviewer the approved design, this plan, the base SHA before Task 1, and current HEAD. Require explicit review of renderer trust boundaries, command routing, refresh ordering, remote/local behavior, SVG contrast, and test claims. Fix and re-review every Critical or Important finding before installation. + +- [ ] **Step 5: Back up and install the verified bundle** + +Quit Stream Deck cleanly. Create a new timestamped backup under: + +```text +$REPO_PARENT/backups/ +``` + +Back up the installed plugin and the Stream Deck + profile, atomically replace only: + +```text +$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/com.simeo.codex-deck.sdPlugin +``` + +with the verified `dist/com.simeo.codex-deck.sdPlugin`, then relaunch Stream Deck. + +- [ ] **Step 6: Verify the live installation** + +Verify the plugin process starts from the canonical installed path, its log reaches `local=ready`, the Plus page retains 8 keypad and 4 Encoder actions, and the Reasoning/Agents/Actions/Usage presets remain persisted. Confirm the installed manifest and plugin bundle hashes match `dist` before the app mutates logs. + +- [ ] **Step 7: Merge locally into main** + +After all gates and live checks are green: + +```bash +git switch main +git merge --ff-only fix/fast-mode-reasoning-controls +npm run check +npm test +git status --short --branch +``` + +Expected: local `main` contains all slice commits, tests pass, and the worktree is clean. Keep the feature branch available for the later upstream pull request; do not push or publish anything. diff --git a/docs/superpowers/plans/2026-08-09-stream-deck-plus-codex-dial.md b/docs/superpowers/plans/2026-08-09-stream-deck-plus-codex-dial.md new file mode 100644 index 0000000..da724ab --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-stream-deck-plus-codex-dial.md @@ -0,0 +1,1334 @@ +# Stream Deck + Codex Dial Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one preset-driven, independently configurable Codex Dial action for Stream Deck + rotation, dial press, touch tap, and live touch-strip feedback. + +**Architecture:** A pure dial domain layer owns settings normalization, presets, selectors, tick expansion, and feedback derivation. A thin Stream Deck Encoder action forwards hardware events to registrations managed by `DeckController`, which dispatches only allow-listed typed bindings through existing controller and relay paths. The renderer snapshot gains optional DOM-derived reasoning effort plus an explicit typed usage-refresh command; custom layout feedback is cached per action instance. + +**Tech Stack:** TypeScript 5.9, Node.js 20+, `@elgato/streamdeck` 2.1, Stream Deck SDK v2 custom Encoder layouts, Node's built-in test runner through `tsx --test`, HTML/CSS/vanilla JavaScript property inspector, existing CDP bridge and authenticated relay. + +--- + +## Execution rules + +- Execute tasks in order because later tasks depend on types and interfaces established earlier. +- Use a fresh implementation subagent for each numbered code task. +- After each task, run a specification-compliance review subagent, then a code-quality review subagent. Fix all accepted findings before the task commit. +- Commit at every task boundary using the exact scoped paths listed in that task. +- Do not commit `.superpowers/`, installed Stream Deck profiles, local bridge metadata, logs, `dist/`, `release/`, or personal paths. +- Preserve unrelated worktree changes. Stage only the paths named in the current task. +- Use `apply_patch` for source edits. +- Follow red-green-refactor: observe the new focused test fail before adding production code. +- Do not change manifest/plugin version numbers. +- Do not run `npm audit fix`; the current lockfile reports pre-existing audit findings and dependency upgrades are outside this feature. + +## File map + +### New files + +- `src/dial-types.ts` — JSON-safe settings, binding IDs, selector/runtime types, and preset constants. +- `src/dial-domain.ts` — settings normalization, binding validation, selector derivation/reconciliation, tick reduction, and feedback derivation. +- `src/dial-action.ts` — Stream Deck `SingletonAction` event adapter for one Encoder action UUID. +- `test/dial-domain.test.ts` — presets, validation, ticks, selectors, and feedback tests. +- `test/dial-action.test.ts` — manifest/build/event-adapter contract tests. +- `static/layouts/codex-dial.json` — 200 x 100 touch-strip feedback layout. +- `static/property-inspector/codex-dial.html` — preset and advanced per-instance configuration UI. +- `static/imgs/dial.svg` and `static/imgs/dial@2x.svg` — original Encoder action artwork. +- `docs/STREAM_DECK_PLUS.md` — user-facing configuration and physical control guide. + +### Modified files + +- `src/types.ts` — optional normalized reasoning effort on `MicroSnapshot`. +- `src/codex-micro-renderer-bridge.ts` — DOM-derived reasoning effort and forced usage-refresh flag. +- `src/codex-relay-client.ts` — remember advertised relay capabilities for mixed-version command gating. +- `src/relay-protocol.ts` — optional snapshot validation and typed `usage-refresh` command. +- `src/codex-relay-server.ts` — execute the typed usage-refresh command. +- `src/controller.ts` — dial registrations, dispatch, selector state, feedback caching, and rendering. +- `src/plugin.ts` — register `CodexDialAction`. +- `static/manifest.json` — Encoder-only action declaration. +- `scripts/build.mjs` — copy dial layout, property inspector, and images into the plugin bundle. +- `test/micro-bridge.test.ts` — reasoning and force-refresh bridge contracts. +- `test/relay.test.ts` — optional snapshot field and usage-refresh protocol tests. +- `test/release-audit.test.ts` — packaged dial asset presence and private-data boundary. +- `README.md` — feature overview and guide link. +- `docs/ARCHITECTURE.md` — Encoder flow and compatibility boundary. +- `docs/MACOS.md` — physical verification and setup link. +- `docs/WINDOWS.md` — compatibility statement without physical-device claim. + +## Task 1: Establish the dial settings and preset domain + +**Files:** +- Create: `src/dial-types.ts` +- Create: `src/dial-domain.ts` +- Create: `test/dial-domain.test.ts` + +- [ ] **Step 1: Add failing preset and normalization tests** + +Create `test/dial-domain.test.ts` with these initial tests: + +```ts +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DEFAULT_ACTION_SELECTOR_ITEMS, + expandDialPreset, + normalizeDialSettings +} from "../src/dial-domain.js"; + +test("status-focused presets expand to the approved independent bindings", () => { + assert.deepEqual(expandDialPreset("reasoning"), { + version: 1, + preset: "reasoning", + customized: false, + rotation: { kind: "paired", counterClockwise: "reasoning.decrease", clockwise: "reasoning.increase" }, + press: "none", + touchTap: "keycap.FAST", + feedback: "reasoning" + }); + assert.deepEqual(expandDialPreset("agents"), { + version: 1, + preset: "agents", + customized: false, + rotation: { kind: "selector", source: "agents", wrap: true, items: [] }, + press: "selector.activate", + touchTap: "keycap.TIME", + feedback: "agent" + }); + assert.deepEqual(expandDialPreset("actions").rotation, { + kind: "selector", source: "actions", wrap: true, items: [...DEFAULT_ACTION_SELECTOR_ITEMS] + }); + assert.deepEqual(expandDialPreset("usage"), { + version: 1, + preset: "usage", + customized: false, + rotation: { kind: "selector", source: "usage", wrap: true, items: [] }, + press: "usage.toggle-overview", + touchTap: "usage.refresh", + feedback: "usage" + }); +}); + +test("malformed settings normalize to a safe preset and reject executable strings", () => { + assert.deepEqual(normalizeDialSettings({}), expandDialPreset("reasoning")); + const normalized = normalizeDialSettings({ + version: 1, + preset: "custom", + customized: true, + rotation: { kind: "paired", counterClockwise: "shell.rm", clockwise: "reasoning.increase" }, + press: "usage.rate-limit-reset", + touchTap: "usage.rate-limit-reset", + feedback: "reasoning" + }); + assert.deepEqual(normalized.rotation, { + kind: "paired", counterClockwise: "none", clockwise: "reasoning.increase" + }); + assert.equal(normalized.press, "usage.rate-limit-reset"); + assert.equal(normalized.touchTap, "none", "reset hold cannot be bound to touch tap"); +}); +``` + +- [ ] **Step 2: Run the focused test and observe the expected failure** + +Run: + +```bash +npx tsx --test test/dial-domain.test.ts +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/dial-domain.js`. + +- [ ] **Step 3: Define JSON-safe dial types and allow-listed IDs** + +Create `src/dial-types.ts` with the following exported contracts: + +```ts +import type { JsonObject } from "@elgato/utils"; + +export const DIAL_PRESETS = ["reasoning", "agents", "actions", "navigation", "usage", "custom"] as const; +export type DialPreset = typeof DIAL_PRESETS[number]; + +export const DIAL_FEEDBACK_MODES = ["auto", "reasoning", "agent", "action", "navigation", "usage", "static"] as const; +export type DialFeedbackMode = typeof DIAL_FEEDBACK_MODES[number]; + +export const DIAL_SELECTOR_SOURCES = ["agents", "actions", "usage"] as const; +export type DialSelectorSource = typeof DIAL_SELECTOR_SOURCES[number]; + +export type DialBindingId = + | "none" | "selector.activate" + | "reasoning.decrease" | "reasoning.increase" + | "new-task" | "host.toggle" + | "usage.refresh" | "usage.toggle-overview" | "usage.rate-limit-reset" + | `micro.${string}` | `joystick.${string}` | `keycap.${string}`; + +export interface PairedDialRotation extends JsonObject { + kind: "paired"; + counterClockwise: DialBindingId; + clockwise: DialBindingId; +} + +export interface SelectorDialRotation extends JsonObject { + kind: "selector"; + source: DialSelectorSource; + wrap: boolean; + items: DialBindingId[]; +} + +export type DialRotation = PairedDialRotation | SelectorDialRotation; + +export interface CodexDialSettings extends JsonObject { + version: 1; + preset: DialPreset; + customized: boolean; + rotation: DialRotation; + press: DialBindingId; + touchTap: DialBindingId; + feedback: DialFeedbackMode; + staticLabel?: string; +} + +export type DialSelectorItem = { + id: string; + label: string; + detail?: string; + binding?: DialBindingId; + agentSlot?: number; + threadKey?: string; +}; + +export type DialRuntimeState = { + selectedId?: string; + usageMode: "auto" | "five-hour" | "weekly"; + usageOverview: boolean; +}; + +export type DialFeedback = { + title: string; + value: string; + detail: string; + indicator: number; + accent: string; +}; +``` + +- [ ] **Step 4: Implement preset expansion and strict normalization** + +Create `src/dial-domain.ts`. Export these constants and functions: + +```ts +import { OFFICIAL_KEYCAP_IDS } from "./keycaps.js"; +import type { + CodexDialSettings, DialBindingId, DialFeedbackMode, DialPreset, DialRotation +} from "./dial-types.js"; + +export const MICRO_SLOTS = ["ACT06", "ACT07", "ACT08", "ACT09", "ACT10_ACT11", "ACT12"] as const; +export const JOYSTICK_DIRECTIONS = ["up", "right", "down", "left"] as const; +export const DEFAULT_ACTION_SELECTOR_ITEMS: readonly DialBindingId[] = MICRO_SLOTS.map((slot): DialBindingId => `micro.${slot}`); + +export function expandDialPreset(preset: DialPreset): CodexDialSettings { + if (preset === "agents") return selectorPreset("agents", "selector.activate", "keycap.TIME", "agent"); + if (preset === "actions") return selectorPreset("actions", "selector.activate", "keycap.SETUP", "action", [...DEFAULT_ACTION_SELECTOR_ITEMS]); + if (preset === "usage") return selectorPreset("usage", "usage.toggle-overview", "usage.refresh", "usage"); + if (preset === "navigation") return pairedPreset("navigation", "joystick.left", "joystick.right", "joystick.up", "joystick.down", "navigation"); + if (preset === "custom") return pairedPreset("custom", "none", "none", "none", "none", "static"); + return pairedPreset("reasoning", "reasoning.decrease", "reasoning.increase", "none", "keycap.FAST", "reasoning"); +} + +export function isDialBindingId(value: unknown, gesture: "rotation" | "press" | "touch" | "selector" = "press"): value is DialBindingId { + if (typeof value !== "string") return false; + const valid = value === "none" || value === "selector.activate" || value === "reasoning.decrease" || + value === "reasoning.increase" || value === "new-task" || value === "host.toggle" || + value === "usage.refresh" || value === "usage.toggle-overview" || value === "usage.rate-limit-reset" || + (value.startsWith("micro.") && MICRO_SLOTS.includes(value.slice(6) as typeof MICRO_SLOTS[number])) || + (value.startsWith("joystick.") && JOYSTICK_DIRECTIONS.includes(value.slice(9) as typeof JOYSTICK_DIRECTIONS[number])) || + (value.startsWith("keycap.") && OFFICIAL_KEYCAP_IDS.includes(value.slice(7) as typeof OFFICIAL_KEYCAP_IDS[number])); + if (!valid) return false; + if (value === "usage.rate-limit-reset" && gesture !== "press") return false; + if (value === "selector.activate" && gesture !== "press") return false; + return true; +} + +export function normalizeDialSettings(input: unknown): CodexDialSettings { + if (!record(input) || input.version !== 1 || !isPreset(input.preset)) return expandDialPreset("reasoning"); + const fallback = expandDialPreset(input.preset); + const rotation = normalizeRotation(input.rotation, fallback.rotation); + const feedback = isFeedback(input.feedback) ? input.feedback : fallback.feedback; + const staticLabel = typeof input.staticLabel === "string" ? input.staticLabel.trim().slice(0, 40) : undefined; + return { + version: 1, + preset: input.preset, + customized: input.customized === true, + rotation, + press: isDialBindingId(input.press, "press") ? input.press : fallback.press, + touchTap: isDialBindingId(input.touchTap, "touch") ? input.touchTap : "none", + feedback, + ...(staticLabel ? { staticLabel } : {}) + }; +} +``` + +Also implement private `record`, `isPreset`, `isFeedback`, `pairedPreset`, `selectorPreset`, and `normalizeRotation` helpers. `normalizeRotation` must preserve the chosen paired/selector kind, validate selector source, validate `wrap`, remove invalid action items, cap action items at 30, and fall back to the preset rotation when the structure is invalid. + +- [ ] **Step 5: Run the focused tests until green** + +Run: + +```bash +npx tsx --test test/dial-domain.test.ts +npm run check +``` + +Expected: both commands exit 0; the focused runner reports 2 passing tests. + +- [ ] **Step 6: Run two-stage subagent review and fix accepted findings** + +Dispatch a specification-compliance reviewer with the approved design spec and Task 1 diff. Then dispatch a code-quality reviewer with the same diff. Re-run the two commands from Step 5 after fixes. + +- [ ] **Step 7: Commit the dial settings slice** + +```bash +git add src/dial-types.ts src/dial-domain.ts test/dial-domain.test.ts +git commit -m "feat: add configurable Codex dial presets" +``` + +## Task 2: Add selector reduction, tick expansion, and feedback derivation + +**Files:** +- Modify: `src/dial-types.ts` +- Modify: `src/dial-domain.ts` +- Modify: `test/dial-domain.test.ts` + +- [ ] **Step 1: Add failing selector and feedback tests** + +Append tests that exercise exact detent counts, selector preview, stable agent identity, and usage feedback: + +```ts +import { + deriveDialFeedback, initialDialRuntimeState, reconcileSelector, reduceDialRotation, + selectorItems +} from "../src/dial-domain.js"; +import type { DialRuntimeView } from "../src/dial-types.js"; + +const view: DialRuntimeView = { + health: "ready", + reasoningEffort: "high", + agents: [ + { id: 0, identity: "mac:thread-a", threadKey: "thread-a", title: "Build", status: "working", contextUsedPercent: 42 }, + { id: 2, identity: "mac:thread-c", threadKey: "thread-c", title: "Review", status: "idle" } + ], + actionLabels: { "micro.ACT06": "Fast", "micro.ACT07": "Approve" }, + usage: { + mode: "five-hour", remainingPercent: 72, resetsAt: 1_800_000, observedAt: 1_000_000, + fiveHourRemaining: 72, weeklyRemaining: 81 + }, + now: 1_000_000 +}; + +test("paired rotation expands every detent in order", () => { + const settings = expandDialPreset("reasoning"); + assert.deepEqual(reduceDialRotation(settings, initialDialRuntimeState(), view, -3).bindings, + ["reasoning.decrease", "reasoning.decrease", "reasoning.decrease"]); + assert.deepEqual(reduceDialRotation(settings, initialDialRuntimeState(), view, 2).bindings, + ["reasoning.increase", "reasoning.increase"]); +}); + +test("agent selector previews without dispatch and reconciles by stable identity", () => { + const settings = expandDialPreset("agents"); + const items = selectorItems(settings, view); + assert.deepEqual(items.map(({ id }) => id), ["mac:thread-a", "mac:thread-c"]); + const rotated = reduceDialRotation(settings, initialDialRuntimeState(), view, 1); + assert.deepEqual(rotated.bindings, []); + assert.equal(rotated.state.selectedId, "mac:thread-c"); + assert.equal(reconcileSelector(rotated.state, [items[1]!, items[0]!]).selectedId, "mac:thread-c"); +}); + +test("feedback reports live reasoning and usage without inventing unavailable state", () => { + assert.deepEqual(deriveDialFeedback(expandDialPreset("reasoning"), initialDialRuntimeState(), view), { + title: "REASONING", value: "HIGH", detail: "TURN TO ADJUST", indicator: 100, accent: "#7f8cff" + }); + const unavailable = deriveDialFeedback(expandDialPreset("reasoning"), initialDialRuntimeState(), { ...view, reasoningEffort: undefined }); + assert.equal(unavailable.value, "UNAVAILABLE"); + const usage = deriveDialFeedback(expandDialPreset("usage"), { ...initialDialRuntimeState(), usageMode: "five-hour" }, view); + assert.equal(usage.value, "72% LEFT"); + assert.match(usage.detail, /RESET/); +}); +``` + +- [ ] **Step 2: Run the focused tests and confirm symbol failures** + +Run `npx tsx --test test/dial-domain.test.ts`. + +Expected: FAIL because the new runtime functions and `DialRuntimeView` are not exported. + +- [ ] **Step 3: Add runtime-view and reducer types** + +Append these contracts to `src/dial-types.ts`: + +```ts +export type DialRuntimeAgent = { + id: number; + identity: string; + threadKey: string; + title: string; + status: string; + contextUsedPercent?: number; +}; + +export type DialRuntimeUsage = { + mode: "auto" | "five-hour" | "weekly"; + remainingPercent?: number; + resetsAt?: number | null; + observedAt?: number; + fiveHourRemaining?: number; + weeklyRemaining?: number; +}; + +export type DialRuntimeView = { + health: "ready" | "degraded" | "offline" | "connecting"; + reasoningEffort?: string; + agents: DialRuntimeAgent[]; + actionLabels: Partial>; + usage?: DialRuntimeUsage; + now: number; +}; +``` + +- [ ] **Step 4: Implement pure selector and feedback functions** + +In `src/dial-domain.ts`, export: + +```ts +export function initialDialRuntimeState(): DialRuntimeState { + return { usageMode: "auto", usageOverview: false }; +} + +export function selectorItems(settings: CodexDialSettings, view: DialRuntimeView): DialSelectorItem[] { + if (settings.rotation.kind !== "selector") return []; + if (settings.rotation.source === "agents") return view.agents.map((agent) => ({ + id: agent.identity, label: agent.title || `Agent ${agent.id + 1}`, detail: agent.status, + agentSlot: agent.id, threadKey: agent.threadKey + })); + if (settings.rotation.source === "usage") return [ + { id: "auto", label: "Automatic" }, + { id: "five-hour", label: "5 hours" }, + { id: "weekly", label: "Weekly" } + ]; + return settings.rotation.items.map((binding) => ({ + id: binding, binding, label: view.actionLabels[binding] ?? bindingLabel(binding) + })); +} + +export function reconcileSelector(state: DialRuntimeState, items: DialSelectorItem[]): DialRuntimeState { + if (!items.length) return { ...state, selectedId: undefined }; + return items.some(({ id }) => id === state.selectedId) ? state : { ...state, selectedId: items[0]!.id }; +} + +export function reduceDialRotation( + settings: CodexDialSettings, state: DialRuntimeState, view: DialRuntimeView, ticks: number +): { state: DialRuntimeState; bindings: DialBindingId[] } { + const count = Number.isFinite(ticks) ? Math.trunc(ticks) : 0; + if (!count) return { state, bindings: [] }; + if (settings.rotation.kind === "paired") { + const binding = count > 0 ? settings.rotation.clockwise : settings.rotation.counterClockwise; + return { state, bindings: Array.from({ length: Math.abs(count) }, () => binding) }; + } + const items = selectorItems(settings, view); + const current = reconcileSelector(state, items); + if (!items.length) return { state: current, bindings: [] }; + const index = Math.max(0, items.findIndex(({ id }) => id === current.selectedId)); + const raw = index + count; + const next = settings.rotation.wrap + ? ((raw % items.length) + items.length) % items.length + : Math.min(items.length - 1, Math.max(0, raw)); + const selected = items[next]!; + return { + state: { + ...current, + selectedId: selected.id, + ...(settings.rotation.source === "usage" ? { usageMode: selected.id as DialRuntimeState["usageMode"] } : {}) + }, + bindings: [] + }; +} +``` + +Implement `deriveDialFeedback` for all seven feedback modes. `auto` follows the paired preset or selector source. The function must uppercase and truncate display strings, use `UNAVAILABLE`, `OFFLINE`, `CONNECTING`, or `DEGRADED` based on health, calculate a bounded 0–100 indicator, calculate usage reset countdown from `view.now`, and return only the five stable layout fields in `DialFeedback`. Implement and export `selectedItem(settings, state, view)` for press activation. + +- [ ] **Step 5: Run focused tests and typecheck** + +```bash +npx tsx --test test/dial-domain.test.ts +npm run check +``` + +Expected: all dial-domain tests pass and TypeScript exits 0. + +- [ ] **Step 6: Run two-stage subagent review, fix, and re-run Step 5** + +The specification reviewer must specifically check no selector executes during rotation and no unavailable reasoning value is guessed. The quality reviewer must check bounded ticks, empty selectors, stable identity, and display truncation. + +- [ ] **Step 7: Commit the reducer and feedback slice** + +```bash +git add src/dial-types.ts src/dial-domain.ts test/dial-domain.test.ts +git commit -m "feat: add dial selectors and live feedback model" +``` + +## Task 3: Extend snapshots with reasoning effort and typed usage refresh + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/codex-micro-renderer-bridge.ts` +- Modify: `src/codex-relay-client.ts` +- Modify: `src/relay-protocol.ts` +- Modify: `src/codex-relay-server.ts` +- Modify: `src/controller.ts` +- Modify: `test/micro-bridge.test.ts` +- Modify: `test/relay.test.ts` + +- [ ] **Step 1: Add failing bridge and relay contract tests** + +Append to `test/micro-bridge.test.ts`: + +```ts +test("renderer snapshot reads current reasoning effort from the live composer and supports forced usage refresh", async () => { + const source = await readFile(new URL("../src/codex-micro-renderer-bridge.ts", import.meta.url), "utf8"); + assert.match(source, /data-selected-reasoning-effort/); + assert.match(source, /reasoningEffort/); + assert.match(source, /codex-deck-force-rate-limit-refresh/); + assert.match(source, /requestUsageRefresh/); +}); +``` + +Append to `test/relay.test.ts`: + +```ts +test("relay accepts optional bounded reasoning effort and typed usage refresh", async () => { + const { parseRelayServerMessage } = await import("../src/relay-protocol.js"); + const valid = { type: "snapshot", protocol: 1, host, observedAt: 1, snapshot: { ...structuredClone(snapshot), reasoningEffort: "high" } }; + assert.notEqual(parseRelayServerMessage(valid), null); + valid.snapshot.reasoningEffort = "x".repeat(65); + assert.equal(parseRelayServerMessage(valid), null); + assert.deepEqual(parseRelayCommand({ kind: "usage-refresh" }), { kind: "usage-refresh" }); +}); +``` + +- [ ] **Step 2: Run focused tests and observe failures** + +```bash +npx tsx --test test/micro-bridge.test.ts test/relay.test.ts +``` + +Expected: FAIL because the source lacks the new marker/method and the relay rejects `usage-refresh`. + +- [ ] **Step 3: Add the optional snapshot field** + +In `src/types.ts`, add to `MicroSnapshot`: + +```ts +/** Current composer effort read from the active Codex reasoning trigger when available. */ +reasoningEffort?: string; +``` + +In `SNAPSHOT_EXPRESSION`, after active-thread discovery, read only the active renderer DOM attribute: + +```js +const reasoningCandidate = document.querySelector('[data-selected-reasoning-effort]') + ?.getAttribute('data-selected-reasoning-effort')?.trim(); +const reasoningEffort = reasoningCandidate && reasoningCandidate.length <= 64 + ? reasoningCandidate + : undefined; +``` + +Include `...(reasoningEffort ? { reasoningEffort } : {})` in the returned snapshot. Do not infer it from encoder history. + +- [ ] **Step 4: Add an explicit force-refresh flag** + +Inside the existing usage-query block, consume this global flag: + +```js +const forceRefreshKey = Symbol.for('codex-deck-force-rate-limit-refresh'); +const forceRefresh = globalThis[forceRefreshKey] === true; +if (forceRefresh) delete globalThis[forceRefreshKey]; +``` + +When `forceRefresh` is true and `query.fetch` exists, await `query.fetch()` before reading `query.state.data`; keep the current non-blocking 15-second behavior for normal background refreshes. + +Add this method to `CodexMicroRendererBridge`: + +```ts +async requestUsageRefresh(): Promise { + await this.ensureConnected(); + await this.evaluate(`(() => { globalThis[Symbol.for('codex-deck-force-rate-limit-refresh')] = true; return true; })()`); + return this.refresh(); +} +``` + +- [ ] **Step 5: Extend the relay with capability-gated `usage-refresh`** + +Add `{ kind: "usage-refresh" }` to `RelayCommand`, add `"usage-refresh"` to `RELAY_CAPABILITIES`, accept the exact object in `parseRelayCommand`, and add `refreshUsage(): Promise` to the relay control interface. In `executeRelayCommand`: + +```ts +if (command.kind === "usage-refresh") return control.refreshUsage(); +``` + +The repository parser is named `parseRelayCommand`; add this exact branch before the keycap fallback: + +```ts +if (value.kind === "usage-refresh" && Object.keys(value).length === 1) return value as RelayCommand; +``` + +Validate `snapshot.reasoningEffort` with: + +```ts +if (value.reasoningEffort != null && + (typeof value.reasoningEffort !== "string" || value.reasoningEffort.length < 1 || value.reasoningEffort.length > 64)) return false; +``` + +In the controller's mobile relay control object, implement: + +```ts +refreshUsage: () => runAndInvalidate(async () => { + await this.microBridge.requestUsageRefresh(); +}) +``` + +In `CodexRelayClient`, store ready-message capabilities and expose exact gating: + +```ts +private capabilities = new Set(); + +supportsCapability(capability: string): boolean { + return this.capabilities.has(capability); +} +``` + +On a `ready` message assign `this.capabilities = new Set(message.capabilities ?? [])`; clear it in `close()` and `disconnected()`. + +Add a controller method that refreshes the healthy account source. For a remote source, send `usage-refresh` only when the remote advertises that capability; otherwise throw `Remote Codex host does not support usage refresh.` For local source, call `requestUsageRefresh`, update `localSnapshot`, and invoke `refreshDisplay`. + +- [ ] **Step 6: Run focused and full relay tests** + +```bash +npx tsx --test test/micro-bridge.test.ts test/relay.test.ts +npm run check +``` + +Expected: focused tests pass and typecheck exits 0. + +- [ ] **Step 7: Run two-stage subagent review and fix accepted findings** + +The specification reviewer checks optional mixed-version compatibility and account-scoped routing. The quality reviewer checks the force flag is consumed once, normal refresh remains non-blocking, the 64-character bound is enforced, and unsupported remote capability fails honestly. + +- [ ] **Step 8: Commit the snapshot and refresh slice** + +```bash +git add src/types.ts src/codex-micro-renderer-bridge.ts src/codex-relay-client.ts src/relay-protocol.ts src/codex-relay-server.ts src/controller.ts test/micro-bridge.test.ts test/relay.test.ts +git commit -m "feat: expose reasoning and refresh usage snapshots" +``` + +## Task 4: Add controller-managed dial registrations and typed dispatch + +**Files:** +- Modify: `src/dial-types.ts` +- Modify: `src/dial-domain.ts` +- Modify: `src/controller.ts` +- Modify: `test/dial-domain.test.ts` + +- [ ] **Step 1: Add failing command-lifecycle and queue tests** + +Add pure lifecycle helpers to the test imports, then append: + +```ts +test("binding lifecycle distinguishes momentary and one-shot commands", () => { + assert.equal(bindingLifecycle("micro.ACT07"), "momentary"); + assert.equal(bindingLifecycle("joystick.left"), "momentary"); + assert.equal(bindingLifecycle("reasoning.increase"), "one-shot"); + assert.equal(bindingLifecycle("keycap.FAST"), "one-shot"); + assert.equal(bindingLifecycle("usage.rate-limit-reset"), "hold"); +}); + +test("serialized queue preserves every detent after async work", async () => { + const seen: number[] = []; + const queue = new DialCommandQueue(); + for (const value of [1, 2, 3]) queue.enqueue(async () => { + await Promise.resolve(); + seen.push(value); + }); + await queue.idle(); + assert.deepEqual(seen, [1, 2, 3]); +}); +``` + +- [ ] **Step 2: Run the focused test and observe missing exports** + +Run `npx tsx --test test/dial-domain.test.ts`. + +Expected: FAIL because `bindingLifecycle` and `DialCommandQueue` are missing. + +- [ ] **Step 3: Implement lifecycle classification and serialized queue** + +In `src/dial-domain.ts`, export: + +```ts +export function bindingLifecycle(binding: DialBindingId): "none" | "one-shot" | "momentary" | "hold" { + if (binding === "none") return "none"; + if (binding === "usage.rate-limit-reset") return "hold"; + if (binding.startsWith("micro.") || binding.startsWith("joystick.")) return "momentary"; + return "one-shot"; +} + +export class DialCommandQueue { + private tail: Promise = Promise.resolve(); + enqueue(operation: () => Promise): void { + this.tail = this.tail.then(operation, operation).catch(() => undefined); + } + idle(): Promise { return this.tail; } +} +``` + +- [ ] **Step 4: Add controller dial registrations** + +Import `DialAction` and the dial domain/types. Add: + +```ts +type DialRegistration = { + action: DialAction; + settings: CodexDialSettings; + state: DialRuntimeState; + queue: DialCommandQueue; + pressed?: { binding: DialBindingId; item?: DialSelectorItem }; + lastFeedback?: string; +}; +``` + +Add `private readonly dials = new Map();` and these public methods: + +```ts +registerDial(action: DialAction, input: unknown): void; +updateDialSettings(action: DialAction, input: unknown): void; +unregisterDial(action: { id: string }): void; +rotateDial(action: DialAction, ticks: number): void; +beginDialPress(action: DialAction): Promise; +finishDialPress(action: DialAction): Promise; +touchDial(action: DialAction): Promise; +``` + +Requirements for their bodies: + +- Normalize every settings payload. +- Keep selector state per action ID. +- Use `reduceDialRotation`; enqueue every returned paired binding and render selectors immediately. +- Resolve `selector.activate` to the current selected agent/action. +- Save the resolved pressed binding so release goes to the same host/command even if selection changes. +- Momentary bindings dispatch down on press and up on release. +- One-shot press bindings run on dial down only. +- Touch momentary bindings run down then up in the same queue operation. +- Hold binding calls existing `beginRateLimitReset` and `finishRateLimitReset` using the dial action identity. +- A successful rate-limit reset release calls `action.showOk()`; a short hold remains a no-op. +- `usage.toggle-overview` flips only that registration's `state.usageOverview`, then re-renders it. +- `selector.activate` resolves the selected item at dial down and stores that resolved item through dial up. +- `none` is a no-op. +- Any dispatch error logs one concise message and calls `action.showAlert()`. + +Map typed bindings only to existing methods: + +```ts +"reasoning.decrease" -> adjustReasoning("decrease") +"reasoning.increase" -> adjustReasoning("increase") +"new-task" -> createTask() +"host.toggle" -> toggleTargetHost() +"usage.refresh" -> refreshUsage() +"micro." -> sendMicroAction(slot, act) +"joystick." -> sendJoystick(direction, act) +"keycap." -> runKeycap(id) on down only +``` + +Extend `sendAgent` to `sendAgent(slot: number, act: 0 | 1, expectedThreadKey?: string)`. Agent selector activation must call `sendAgent(item.agentSlot, act, item.threadKey)` and reject down dispatch when the current routed slot no longer has the expected thread key. Existing keypad callers omit the third argument. Do not accept arbitrary strings beyond `isDialBindingId`. + +- [ ] **Step 5: Build the runtime view and render feedback** + +Add private `dialRuntimeView(settings, state)` and `renderDial(registration)` methods. The view uses: + +- `targetHealth()` for ordinary dial health. +- `targetSnapshot()?.reasoningEffort` for reasoning. +- `routedSlots` filtered to entries with `threadKey`, with identity `${host.hostId}:${threadKey}`. +- Current Micro layout keycap IDs mapped to human labels. +- `accountUsageSource()` and `selectUsageWindow` for usage. + +Call `action.setFeedback` only when `JSON.stringify(feedback)` differs from `registration.lastFeedback`, mapping the color string to the bar definition: + +```ts +await action.setFeedback({ + title: feedback.title, + value: feedback.value, + detail: feedback.detail, + indicator: feedback.indicator, + accent: { value: 100, bar_fill_c: feedback.accent } +}); +``` + +Add all registered dials to `renderAll()`. + +On register and settings update, call `action.setTriggerDescription` with instance-specific descriptions derived from normalized settings. Use `Rotate: "Adjust"` for paired mode or `Rotate: "Select"` for selectors; use the configured press/touch labels for `Push` and `Touch`. Failure to update descriptions is logged once per distinct error and does not stop feedback rendering. + +- [ ] **Step 6: Run tests and typecheck** + +```bash +npx tsx --test test/dial-domain.test.ts +npm run check +``` + +Expected: tests pass and TypeScript exits 0. + +- [ ] **Step 7: Run two-stage subagent review and fix accepted findings** + +The specification reviewer checks host routing, no rotation confirmation, no selector execution during rotation, independent per-action state, and hold protection. The quality reviewer checks release uses the saved pressed binding, queue errors do not poison later operations, settings normalization occurs at every boundary, and feedback caching is effective. + +- [ ] **Step 8: Commit the controller runtime slice** + +```bash +git add src/dial-types.ts src/dial-domain.ts src/controller.ts test/dial-domain.test.ts +git commit -m "feat: dispatch configurable Codex dial gestures" +``` + +## Task 5: Register the Stream Deck Encoder action and feedback layout + +**Files:** +- Create: `src/dial-action.ts` +- Create: `test/dial-action.test.ts` +- Create: `static/layouts/codex-dial.json` +- Create: `static/imgs/dial.svg` +- Create: `static/imgs/dial@2x.svg` +- Modify: `src/plugin.ts` +- Modify: `static/manifest.json` +- Modify: `scripts/build.mjs` + +- [ ] **Step 1: Add failing manifest, layout, build, and adapter tests** + +Create `test/dial-action.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const text = (path: string) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +test("manifest exposes one Encoder-only Codex Dial with a custom layout", async () => { + const manifest = JSON.parse(await text("static/manifest.json")) as { + Actions: Array<{ UUID: string; Controllers?: string[]; Encoder?: { layout?: string; TriggerDescription?: object } }>; + }; + const dial = manifest.Actions.find(({ UUID }) => UUID === "com.simeo.codex-deck.codex-dial"); + assert.deepEqual(dial?.Controllers, ["Encoder"]); + assert.equal(dial?.Encoder?.layout, "static/layouts/codex-dial.json"); + assert.deepEqual(Object.keys(dial?.Encoder?.TriggerDescription ?? {}).sort(), ["Push", "Rotate", "Touch"]); +}); + +test("dial action forwards all four Encoder event families", async () => { + const source = await text("src/dial-action.ts"); + for (const handler of ["onDialRotate", "onDialDown", "onDialUp", "onTouchTap"]) assert.match(source, new RegExp(handler)); + assert.match(source, /action\.isDial\(\)/); + assert.doesNotMatch(source, /onKeyDown|onKeyUp/); +}); + +test("custom layout stays inside the 200 by 100 Encoder canvas", async () => { + const layout = JSON.parse(await text("static/layouts/codex-dial.json")) as { items: Array<{ key: string; rect: number[] }> }; + assert.deepEqual(layout.items.map(({ key }) => key).sort(), ["accent", "detail", "indicator", "title", "value"]); + for (const { rect: [x, y, width, height] } of layout.items) { + assert.ok(x >= 0 && y >= 0 && x + width <= 200 && y + height <= 100); + } +}); + +test("build copies every Encoder asset", async () => { + const source = await text("scripts/build.mjs"); + for (const path of ["codex-dial.html", "codex-dial.json", "dial.svg", "dial@2x.svg"]) assert.match(source, new RegExp(path.replace(".", "\\."))); +}); +``` + +- [ ] **Step 2: Run the new test and observe failures** + +Run `npx tsx --test test/dial-action.test.ts`. + +Expected: FAIL because the adapter/layout do not exist and the manifest lacks the action. + +- [ ] **Step 3: Create the thin Encoder event adapter** + +Create `src/dial-action.ts`: + +```ts +import { + action, type DialDownEvent, type DialRotateEvent, type DialUpEvent, + type DidReceiveSettingsEvent, SingletonAction, type TouchTapEvent, + type WillAppearEvent, type WillDisappearEvent +} from "@elgato/streamdeck"; +import type { DeckController } from "./controller.js"; +import type { CodexDialSettings } from "./dial-types.js"; + +@action({ UUID: "com.simeo.codex-deck.codex-dial" }) +export class CodexDialAction extends SingletonAction { + constructor(private readonly controller: DeckController) { super(); } + + override onWillAppear(ev: WillAppearEvent): void { + if (ev.action.isDial()) this.controller.registerDial(ev.action, ev.payload.settings); + } + override onDidReceiveSettings(ev: DidReceiveSettingsEvent): void { + if (ev.action.isDial()) this.controller.updateDialSettings(ev.action, ev.payload.settings); + } + override onWillDisappear(ev: WillDisappearEvent): void { + this.controller.unregisterDial(ev.action); + } + override onDialRotate(ev: DialRotateEvent): void { + this.controller.rotateDial(ev.action, ev.payload.ticks); + } + override async onDialDown(ev: DialDownEvent): Promise { + await this.controller.beginDialPress(ev.action); + } + override async onDialUp(ev: DialUpEvent): Promise { + await this.controller.finishDialPress(ev.action); + } + override async onTouchTap(ev: TouchTapEvent): Promise { + if (!ev.payload.hold) await this.controller.touchDial(ev.action); + } +} +``` + +Register `new CodexDialAction(controller)` in `src/plugin.ts`. + +- [ ] **Step 4: Add the Encoder manifest entry** + +Add one action object without changing existing action objects: + +```json +{ + "UUID": "com.simeo.codex-deck.codex-dial", + "Name": "Codex Dial", + "Tooltip": "Configurable Stream Deck + dial with independent rotation, press, touch, and live Codex feedback.", + "Icon": "static/imgs/category-icon", + "PropertyInspectorPath": "static/property-inspector/codex-dial.html", + "Controllers": ["Encoder"], + "Encoder": { + "Icon": "static/imgs/dial", + "layout": "static/layouts/codex-dial.json", + "StackColor": "#7F8CFF", + "TriggerDescription": { + "Rotate": "Adjust or select", + "Push": "Run the configured press action", + "Touch": "Run the configured touch action" + } + }, + "States": [{ "Image": "static/imgs/key", "Title": "" }] +} +``` + +- [ ] **Step 5: Create and validate the custom layout** + +Create `static/layouts/codex-dial.json` with exactly five keyed items: + +```json +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "com.simeo.codex-deck.codex-dial.layout", + "items": [ + { "key": "accent", "type": "bar", "rect": [8, 88, 184, 6], "value": 100, "range": { "min": 0, "max": 100 }, "bar_bg_c": "#242838", "bar_fill_c": "#7F8CFF", "border_w": 0 }, + { "key": "title", "type": "text", "rect": [10, 5, 180, 20], "value": "CODEX", "font": { "size": 12, "weight": 600 }, "color": "#AEB7FF", "alignment": "center" }, + { "key": "value", "type": "text", "rect": [8, 25, 184, 34], "value": "READY", "font": { "size": 24, "weight": 700 }, "color": "#F4F6FF", "alignment": "center" }, + { "key": "detail", "type": "text", "rect": [8, 61, 184, 20], "value": "TURN OR PRESS", "font": { "size": 11, "weight": 500 }, "color": "#AAB0C2", "alignment": "center" }, + { "key": "indicator", "type": "bar", "rect": [8, 82, 184, 4], "value": 0, "range": { "min": 0, "max": 100 }, "bar_bg_c": "#242838", "bar_fill_c": "#7F8CFF", "border_w": 0 } + ] +} +``` + +- [ ] **Step 6: Add original dial icons and build copies** + +Create `static/imgs/dial.svg` as original artwork: + +```svg + + + + + +``` + +Create `static/imgs/dial@2x.svg`: + +```svg + + + + + +``` + +These assets must not contain copied Codex or Elgato artwork. + +Update `scripts/build.mjs` to create `static/layouts` in the output and copy: + +```text +static/imgs/dial.svg +static/imgs/dial@2x.svg +static/layouts/codex-dial.json +static/property-inspector/codex-dial.html +``` + +The property inspector file is added in Task 6; Task 5's build test may remain red only for that missing file until Task 6 if `cp` executes during `npm run build`. To keep every task green, create a minimal valid `codex-dial.html` in Task 5 containing the Stream Deck socket registration and no fields, then replace it in Task 6. + +- [ ] **Step 7: Run focused tests, build, and Stream Deck validation** + +```bash +npx tsx --test test/dial-action.test.ts +npm run check +npm run validate +``` + +Expected: tests pass, typecheck exits 0, build succeeds, and `streamdeck validate` reports a valid plugin. + +- [ ] **Step 8: Run two-stage subagent review and fix accepted findings** + +The specification reviewer checks a single Encoder-only action, all event families, no keypad regression, and original assets. The quality reviewer checks `isDial`, touch-hold handling, layout bounds/schema, manifest paths, and build copies. + +- [ ] **Step 9: Commit the Encoder integration slice** + +```bash +git add src/dial-action.ts src/plugin.ts static/manifest.json static/layouts/codex-dial.json static/imgs/dial.svg static/imgs/dial@2x.svg static/property-inspector/codex-dial.html scripts/build.mjs test/dial-action.test.ts +git commit -m "feat: register the Stream Deck Plus Codex Dial" +``` + +## Task 6: Build the configurable property inspector + +**Files:** +- Modify: `static/property-inspector/codex-dial.html` +- Modify: `test/dial-action.test.ts` + +- [ ] **Step 1: Add failing property-inspector contract tests** + +Append: + +```ts +test("property inspector exposes presets and independent gesture controls", async () => { + const source = await text("static/property-inspector/codex-dial.html"); + for (const id of ["preset", "rotation-kind", "counter-clockwise", "clockwise", "selector-source", "selector-items", "wrap", "press", "touch-tap", "feedback", "static-label"]) { + assert.match(source, new RegExp(`id=["']${id}["']`)); + } + assert.match(source, /setSettings/); + assert.match(source, /version:\s*1/); + assert.match(source, /customized:\s*true/); + assert.match(source, /usage\.rate-limit-reset/); +}); +``` + +- [ ] **Step 2: Run the focused test and observe missing controls** + +Run `npx tsx --test test/dial-action.test.ts`. + +Expected: FAIL on the first missing field ID. + +- [ ] **Step 3: Implement the full property inspector** + +Replace the minimal inspector with a dark, responsive form using the exact field IDs from Step 1. The JavaScript must: + +1. Register the property inspector socket using `connectElgatoStreamDeckSocket`. +2. Parse incoming settings and default to the Reasoning preset. +3. Hold a local `PRESETS` object matching `expandDialPreset` exactly. +4. Populate binding selects from a hard-coded allow-listed catalog grouped as None, Selector, Reasoning, Agents/actions, Navigation, Codex keycaps, Usage, and Host. +5. Hide paired controls in selector mode and selector controls in paired mode. +6. Show selector item checkboxes with up/down ordering controls only for the Actions source. +7. Show static label only for static feedback. +8. Exclude `usage.rate-limit-reset` from rotation and touch catalogs; include it in press. +9. On preset change, replace all fields with the chosen preset and persist `customized: false`. +10. On any other field change, serialize the complete version-1 settings and persist `customized: true`. +11. Cap the static label at 40 characters and selected action items at 30. +12. Send only `setSettings`; do not send arbitrary plugin messages. + +Use this persistence function verbatim: + +```js +function persist(customized = true) { + const payload = readForm(); + payload.version = 1; + payload.customized = customized; + settings = payload; + if (websocket?.readyState === WebSocket.OPEN) { + websocket.send(JSON.stringify({ event: "setSettings", context, payload })); + } +} +``` + +Every catalog value must match `isDialBindingId`; do not expose user-entered command IDs. + +- [ ] **Step 4: Run focused tests and validate the bundle** + +```bash +npx tsx --test test/dial-action.test.ts +npm run validate +``` + +Expected: property-inspector tests pass and plugin validation succeeds. + +- [ ] **Step 5: Run two-stage subagent review and fix accepted findings** + +The specification reviewer checks preset parity and independent controls. The quality reviewer compares every UI value to runtime allow lists, checks reset-credit restrictions, verifies ordered selector serialization, and tests reconnect/settings initialization logic by inspection. + +- [ ] **Step 6: Commit the property inspector slice** + +```bash +git add static/property-inspector/codex-dial.html test/dial-action.test.ts +git commit -m "feat: configure Codex dials per knob" +``` + +## Task 7: Document the feature and strengthen packaged-artifact checks + +**Files:** +- Create: `docs/STREAM_DECK_PLUS.md` +- Modify: `README.md` +- Modify: `docs/ARCHITECTURE.md` +- Modify: `docs/MACOS.md` +- Modify: `docs/WINDOWS.md` +- Modify: `test/release-audit.test.ts` + +- [ ] **Step 1: Add failing documentation and artifact tests** + +Extend the `test/release-audit.test.ts` imports with `existsSync` from `node:fs` and `fileURLToPath` from `node:url`. Append a test that runs `npm run build`, then asserts these bundle paths exist: + +```ts +const packaged = fileURLToPath(new URL("../dist/com.simeo.codex-deck.sdPlugin/", import.meta.url)); +for (const relative of [ + "static/property-inspector/codex-dial.html", + "static/layouts/codex-dial.json", + "static/imgs/dial.svg", + "static/imgs/dial@2x.svg" +]) assert.equal(existsSync(join(packaged, relative)), true, `missing ${relative}`); +``` + +Add a documentation contract test in `test/dial-action.test.ts` that asserts `README.md` links `docs/STREAM_DECK_PLUS.md` and that the guide contains `Reasoning`, `Agents`, `Actions`, `Usage`, `rotate`, `press`, `touch`, `macOS`, and `Windows`. + +- [ ] **Step 2: Run focused tests and observe the missing guide failure** + +```bash +npx tsx --test test/dial-action.test.ts test/release-audit.test.ts +``` + +Expected: FAIL because `docs/STREAM_DECK_PLUS.md` does not exist and README lacks the link. + +- [ ] **Step 3: Write the Stream Deck + guide** + +Create `docs/STREAM_DECK_PLUS.md` with: + +- Requirements and how to add `Codex Dial` to a Stream Deck + knob. +- A table for the Status-focused four-knob defaults. +- Exact rotation, press, and touch behavior for paired and selector modes. +- Property-inspector field reference. +- Rate-limit reset hold restriction. +- Reasoning `UNAVAILABLE` fallback explanation. +- Single-host and multi-host routing behavior. +- macOS physical verification section. +- Windows compatibility section explicitly stating CI/build coverage is not physical-device verification. +- Troubleshooting for `NO ITEMS`, `OFFLINE`, `DEGRADED`, `CONNECTING`, and stale feedback. + +- [ ] **Step 4: Update existing docs** + +- Add a feature bullet and guide link to `README.md`. +- Add the Encoder action, optional reasoning field, typed usage refresh, and feedback caching to `docs/ARCHITECTURE.md`. +- Add the guide link and Stream Deck + setup note to `docs/MACOS.md`. +- Add the same guide link and compatibility boundary to `docs/WINDOWS.md` without claiming physical testing. + +- [ ] **Step 5: Run documentation, build, validation, and release audit** + +```bash +npx tsx --test test/dial-action.test.ts test/release-audit.test.ts test/release-docs.test.ts +npm run validate +npm run audit:release +``` + +Expected: all focused tests pass, validation succeeds, and release audit reports success. + +- [ ] **Step 6: Run two-stage subagent review and fix accepted findings** + +The specification reviewer checks the approved defaults and testing claims. The quality reviewer checks all links/paths, packaged artifacts, private-data exclusion, and documentation consistency with manifest/property-inspector names. + +- [ ] **Step 7: Commit the documentation slice** + +```bash +git add docs/STREAM_DECK_PLUS.md README.md docs/ARCHITECTURE.md docs/MACOS.md docs/WINDOWS.md test/dial-action.test.ts test/release-audit.test.ts +git commit -m "docs: explain configurable Stream Deck Plus dials" +``` + +## Task 8: Run full automated verification and independent QA review + +**Files:** +- Modify only files required by accepted review findings. + +- [ ] **Step 1: Run the required clean verification sequence** + +```bash +npm ci +npm run check +npm test +npm run validate +npm run audit:release +git diff --check main...HEAD +git status --short --branch +``` + +Expected: + +- `npm ci` completes without modifying tracked files. +- TypeScript exits 0. +- Full test suite reports zero failures. +- Stream Deck validation succeeds. +- Release audit succeeds. +- Diff check reports no whitespace errors. +- Status lists no unintended tracked or untracked files. + +- [ ] **Step 2: Dispatch a final specification-compliance subagent** + +Give the reviewer the approved design spec, this plan, and `git diff main...HEAD`. Require a requirement-by-requirement report with file/line evidence and explicit checks for: + +- Per-instance independence +- Paired and selector rotation +- Separate press and touch +- No reasoning confirmation +- Status-focused defaults +- Honest reasoning unavailable state +- Keypad/profile compatibility +- Relay and account-usage routing +- No arbitrary command surface +- Documentation/testing claim accuracy + +- [ ] **Step 3: Dispatch a final code-quality/QA subagent** + +Require review of settings trust boundaries, event lifecycles, async queue behavior, pressed-target stability, selector identity, feedback caching, renderer compatibility, relay version tolerance, property-inspector parity, and test gaps. + +- [ ] **Step 4: Apply accepted review fixes test-first** + +For each accepted functional issue, add or tighten a failing test, run it to observe failure, apply the narrow fix, and re-run the focused test. Stage only changed feature files. + +- [ ] **Step 5: Commit review fixes if any** + +```bash +git add src/dial-types.ts src/dial-domain.ts src/dial-action.ts src/types.ts src/codex-micro-renderer-bridge.ts src/codex-relay-client.ts src/relay-protocol.ts src/codex-relay-server.ts src/controller.ts src/plugin.ts static/manifest.json static/layouts/codex-dial.json static/imgs/dial.svg static/imgs/dial@2x.svg static/property-inspector/codex-dial.html scripts/build.mjs test/dial-domain.test.ts test/dial-action.test.ts test/micro-bridge.test.ts test/relay.test.ts test/release-audit.test.ts README.md docs/ARCHITECTURE.md docs/MACOS.md docs/WINDOWS.md docs/STREAM_DECK_PLUS.md +git commit -m "fix: address Codex dial review findings" +``` + +If no files changed, do not create an empty commit. + +- [ ] **Step 6: Re-run the complete verification sequence from Step 1** + +Expected: every command succeeds and the worktree is clean. + +## Task 9: Install the development build and perform physical Stream Deck + QA + +**Files:** +- No repository files unless an observed bug requires a test-first fix. +- External backups under the existing task workspace `work/backups/` only. +- Installed plugin/profile paths under the user's Stream Deck application support only. + +- [ ] **Step 1: Record exact test environment** + +Record without committing personal identifiers: + +```bash +sw_vers +/Applications/Stream\ Deck.app/Contents/MacOS/Stream\ Deck --version 2>/dev/null || true +git rev-parse HEAD +``` + +Read Stream Deck device metadata to confirm the target is the 4 x 2 Stream Deck +, not Stream Deck 2. + +- [ ] **Step 2: Create recoverable backups** + +Copy the currently installed `com.simeo.codex-deck.sdPlugin` and the Stream Deck + profile root into timestamped directories under the task's existing `work/backups/`. Resolve exact source paths read-only before copying. Do not overwrite an earlier backup. + +- [ ] **Step 3: Install the validated development bundle** + +Stop only the Codex Deck plugin process if possible; otherwise quit and reopen Stream Deck without changing other profiles. Replace the installed plugin directory with the validated contents of `dist/com.simeo.codex-deck.sdPlugin`. Preserve user-only permissions and restart the plugin/app. + +- [ ] **Step 4: Configure the Status-focused four-dial page** + +On the existing Stream Deck + Codex page, add four `com.simeo.codex-deck.codex-dial` Encoder actions with preset settings: + +```text +Dial 1: reasoning +Dial 2: agents +Dial 3: actions +Dial 4: usage +``` + +Do not alter the Stream Deck 2 profile. Preserve the existing eight keypad actions. + +- [ ] **Step 5: Run the physical behavior matrix** + +Verify and record pass/fail for: + +1. Reasoning counter-clockwise/clockwise changes one level per detent with no confirmation. +2. Reasoning press does nothing; reasoning touch toggles Fast. +3. Agent rotation previews occupied tasks without focus change; press focuses the previewed task; touch opens Tasks. +4. Action rotation previews without execution; press runs the selected action; touch opens Settings. +5. Usage rotates Automatic/5-hour/Weekly; press toggles overview; touch refreshes current data. +6. Every touch region acts independently. +7. Rapid multi-tick rotation preserves count and order. +8. Feedback responds to Codex-side reasoning changes. +9. Disconnected/reconnected states render honestly and recover. +10. Existing eight keypad actions still operate. +11. Settings survive Stream Deck and Codex restarts. + +- [ ] **Step 6: Fix any observed defect test-first and commit it** + +For each defect, add a focused automated regression test, observe it fail, implement the narrow fix, run focused plus full verification, and commit: + +```bash +git add src/dial-types.ts src/dial-domain.ts src/dial-action.ts src/types.ts src/codex-micro-renderer-bridge.ts src/codex-relay-client.ts src/relay-protocol.ts src/codex-relay-server.ts src/controller.ts src/plugin.ts static/manifest.json static/layouts/codex-dial.json static/imgs/dial.svg static/imgs/dial@2x.svg static/property-inspector/codex-dial.html scripts/build.mjs test/dial-domain.test.ts test/dial-action.test.ts test/micro-bridge.test.ts test/relay.test.ts test/release-audit.test.ts README.md docs/ARCHITECTURE.md docs/MACOS.md docs/WINDOWS.md docs/STREAM_DECK_PLUS.md +git commit -m "fix: correct Stream Deck Plus dial behavior" +``` + +- [ ] **Step 7: Re-run the physical matrix after fixes** + +Expected: all eleven checks pass. If hardware or Codex state prevents a check, report it as unverified rather than passing it by inference. + +## Task 10: Merge locally to main and verify the merged result + +**Files:** +- No new source files beyond prior tasks. + +- [ ] **Step 1: Invoke the finishing-development-branch workflow** + +Confirm `feature/stream-deck-plus-dials` is clean, all checks pass, and physical QA evidence is recorded. Review `git log --oneline main..HEAD` and `git diff --stat main...HEAD`. + +- [ ] **Step 2: Merge with a normal non-destructive merge** + +```bash +git switch main +git merge --no-ff feature/stream-deck-plus-dials -m "merge: add configurable Stream Deck Plus dials" +``` + +Do not reset, force-push, or delete the feature branch during this task. + +- [ ] **Step 3: Verify from merged main** + +```bash +npm ci +npm run check +npm test +npm run validate +npm run audit:release +git status --short --branch +git log -1 --oneline --decorate +``` + +Expected: every command succeeds, status is clean on `main`, and the latest commit is the merge commit. + +- [ ] **Step 4: Prepare the upstream pull-request handoff** + +Check authenticated GitHub state without publishing. If the user has no fork remote, create a fork only after confirming the authenticated account, add it as `origin`, and retain `dazer1234/codex-stream-deck` as `upstream`. Push the feature branch and open the pull request only if external publication remains authorized at that point. + +The pull-request body must include: + +- Feature summary and screenshots or photos only if the user approves sharing them. +- Commit/test scope. +- Exact Codex, Stream Deck, macOS, and Stream Deck + model versions. +- Automated command results. +- Physical macOS behavior matrix. +- Explicit statement: Windows received CI/build validation but no physical Stream Deck + verification. +- Renderer-internal compatibility caveat. +- No personal profile data or proprietary assets. diff --git a/docs/superpowers/plans/2026-08-10-reasoning-dial-immediate-feedback.md b/docs/superpowers/plans/2026-08-10-reasoning-dial-immediate-feedback.md new file mode 100644 index 0000000..4396c6f --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-reasoning-dial-immediate-feedback.md @@ -0,0 +1,266 @@ +# Reasoning Dial Immediate Feedback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Update Reasoning dial feedback immediately from Codex's confirmed result while repairing current live model discovery without weakening the Ultra guard. + +**Architecture:** Extend reasoning execution with an optional confirmed effort and commit it to the matching host snapshot before immediately rendering dials. Local commits advance `localSnapshotGeneration`; remote commits use an additive protocol-v1 `reasoning-feedback` opt-in and a forced post-command snapshot barrier before the result. Collect bounded visible ordinary-DOM leaf text candidates beneath the unique visible reasoning trigger, strictly normalize each, and accept only when they identify exactly one descriptor-safe, bounded, fully validated `models/list` record whose ordered supported efforts remain authoritative. + +**Tech Stack:** TypeScript, Node test runner via `tsx --test`, Elgato Stream Deck SDK, Electron CDP renderer evaluation, WebSocket relay protocol, Swift source contract. + +--- + +### Task 1: Recover the current live reasoning model from visible leaf candidates + +**Files:** +- Modify: `src/codex-micro-renderer-bridge.ts` +- Test: `test/micro-bridge.test.ts` + +- [ ] **Step 1: Write the failing current-shape DOM-leaf test** + +Add a fixture in which the unique visible reasoning trigger reports effort `high` and its ordinary DOM descendants contain hidden measurement label `5.3 Codex Spark` plus multiple visible leaf text candidates, including effort `high` and model label `5.6 Sol`. Pair it with a validated `models/list` record whose `displayName` is `GPT-5.6-Sol`, model ID is `gpt-5.6-sol`, and `supportedReasoningEfforts` has a distinctive test order. Assert: + +```ts +assert.deepEqual(readActiveReasoningMetadata([trigger], reactRootFiber, isVisible), { + currentEffort: "high", + modelId: "gpt-5.6-sol", + supportedEfforts: expectedEffortsFromMatchedRecord +}); +``` + +Also prove unmatched visible candidates such as `high` are ignored and repeated/unmatched DOM leaves do not create ambiguity when exactly one validated catalog record matches. Assert that zero matching records, more than one distinct matching `displayName` record across all normalized candidates, label-normalization collisions that identify distinct records, React-only sibling/`selectedValue` models, hidden/measurement candidates, accessors, proxies, malformed strings/records, and DOM/catalog depth, node, array, query, or property exhaustion return `undefined` without invoking executable properties. Include a case proving the hidden `5.3 Codex Spark` measurement label cannot select its catalog record. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `npx tsx --test --test-name-pattern='visible reasoning model leaf|reasoning metadata' test/micro-bridge.test.ts` + +Expected: FAIL because the current extractor authorizes a model from React `selectedValue`/sibling data and does not map the visible ordinary-DOM label to a validated catalog `displayName`. + +- [ ] **Step 3: Implement bounded DOM-leaf discovery and catalog resolution** + +Replace React model discovery with a helper that, under the unique visible semantic reasoning trigger, collects all bounded visible, non-hidden, non-measurement ordinary-DOM leaf text candidates with explicit string/node/depth bounds. Normalize each bounded safe DOM candidate and catalog `displayName` by trimming, ASCII-case-folding, and tokenizing only on spaces/hyphens; remove only an exact leading `gpt` token from catalog values, then compare complete token sequences, never substrings or fuzzy matches. + +Replace the separate model-ID effort lookup with bounded `models/list` extraction that fully validates each candidate record as safe own data before comparing its normalized `displayName` against every normalized DOM candidate. Ignore unmatched candidates such as effort `high`; accept only when the comparison yields exactly one distinct matching validated record, and return that same record's safe model ID and `supportedReasoningEfforts` in original order. Do not inspect or accept React `selectedValue` or sibling model values as model authorization. Reuse descriptor-safe own-data readers for cache data and fail closed on zero matches, more than one distinct matching record, malformed/accessor/proxy-backed data, any exhausted bound, or traversal ambiguity. + +- [ ] **Step 4: Run focused and bridge tests** + +Run: + +```bash +npx tsx --test --test-name-pattern='reasoning model|reasoning metadata' test/micro-bridge.test.ts +npx tsx --test test/micro-bridge.test.ts +``` + +Expected: all selected tests and the full bridge file pass. + +- [ ] **Step 5: Commit the slice** + +```bash +git add src/codex-micro-renderer-bridge.ts test/micro-bridge.test.ts +git commit -m "fix: read current Codex reasoning model" +``` + +This forward correction supersedes the model-discovery behavior introduced by commits `c9067e5`, `3f8ca81`, and `3c6c7f2`; preserve those commits in history rather than rewriting them. + +### Task 2: Return confirmed reasoning execution data + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/codex-micro-renderer-bridge.ts` +- Modify: `src/controller.ts` for mechanical outcome extraction only +- Modify: `src/codex-relay-server.ts` for mechanical outcome extraction only +- Modify: `test/controller-dial.test.ts` and `test/relay.test.ts` stubs for the structured bridge result +- Test: `test/micro-bridge.test.ts` + +- [ ] **Step 1: Write failing result and serialized-expression tests** + +Define the wished-for API in tests: + +```ts +assert.deepEqual( + await bridge.adjustReasoning("increase", { includeUltra: false }), + { outcome: "applied", reasoningEffort: "xhigh" } +); +assert.deepEqual( + await bridge.adjustReasoning("increase", { includeUltra: false }), + { outcome: "blocked-ultra", reasoningEffort: "max" } +); +``` + +Execute the real serialized expressions. Prove a failed or unconfirmed interior command does not fabricate an effort, a proven boundary no-op may return the unchanged effort, and concurrent detents remain serialized. + +- [ ] **Step 2: Run and verify RED** + +Run: `npx tsx --test --test-name-pattern='confirmed reasoning|guarded reasoning' test/micro-bridge.test.ts` + +Expected: FAIL because `adjustReasoning` returns only outcome strings. + +- [ ] **Step 3: Add the structured execution type** + +Add to `src/types.ts`: + +```ts +export type ReasoningAdjustmentExecution = { + outcome: ReasoningAdjustmentResult; + reasoningEffort?: string; +}; +``` + +Make all bridge reasoning paths return this type. Poll `readActiveReasoningEffort` after the verified command, include only safe confirmed values, preserve the global mutex and uncertainty reservation, and fail honestly when an interior transition cannot be confirmed. Mechanically extract `.outcome` in the controller and relay server so existing behavior remains type-safe until Task 3 consumes the new effort. Update typed test stubs without adding immediate-render or relay-effort behavior early. + +- [ ] **Step 4: Run bridge tests and typecheck** + +Run: + +```bash +npx tsx --test test/micro-bridge.test.ts +npm run check +``` + +Expected: bridge tests and the full TypeScript check pass; relay/controller behavior remains unchanged except for consuming the structured outcome. + +- [ ] **Step 5: Commit the slice** + +```bash +git add src/types.ts src/codex-micro-renderer-bridge.ts src/controller.ts src/codex-relay-server.ts test/micro-bridge.test.ts test/controller-dial.test.ts test/relay.test.ts +git commit -m "feat: return confirmed reasoning effort" +``` + +### Task 3: Propagate and render confirmed effort without stale regressions + +**Files:** +- Modify: `src/relay-protocol.ts` +- Modify: `src/codex-relay-server.ts` +- Modify: `src/codex-relay-client.ts` +- Modify: `src/controller.ts` +- Modify: `ios/CodexDeckMobile/Models/RelayModels.swift` only if decoding requires an explicit ignored field +- Test: `test/relay.test.ts` +- Test: `test/controller-dial.test.ts` +- Test: `ios/CodexDeckMobileTests/MobileMergeTests.swift` only if Swift decoding changes + +- [ ] **Step 1: Write failing protocol, ordering, generation, and no-poll tests** + +Add `RELAY_REASONING_FEEDBACK_CAPABILITY = "reasoning-feedback"` to the wished-for protocol. Cover a capable client command and successful result with these exact additive v1 shapes: + +```json +{"kind":"reasoning","direction":"increase","includeUltra":false,"includeReasoningFeedback":true} +{"type":"result","protocol":1,"requestId":"r1","ok":true,"outcome":"applied","reasoningEffort":"xhigh"} +``` + +Reject `includeReasoningFeedback: false`, unknown/extra command keys, blank/oversized/malformed effort, effort on failure, effort on a non-reasoning or non-opted-in pending command, accessors, symbols, and extra result keys. Prove only a request recorded against the current ready connection generation and host can patch the retained remote snapshot. + +Add explicit rolling-upgrade cases: + +- Old client to new server sends no feedback flag; even if the new bridge confirms an effort, the server returns no `reasoningEffort` key. +- New client to old server sees no `reasoning-feedback` capability, sends the legacy command shape, accepts the legacy reasoning outcome, and preserves the existing outcome-less unrestricted mapping when `reasoning-policy` is absent. +- New client to new server advertises/detects `reasoning-feedback`, sends `includeReasoningFeedback: true`, and receives the exact confirmed effort. +- Restricted reasoning remains rejected before send unless the same current-ready peer also advertises `reasoning-policy`. + +Add a deterministic server/client ordering test in one connection generation. Hold a pre-command `control.refresh()`, execute an opted-in reasoning command, and prove no result is sent while that read is held. Release it, then assert the socket/client observes the old snapshot, a forced fresh post-command snapshot, and only then the result. After the result patches the retained remote snapshot, prove no pre-command snapshot can arrive or commit and regress the displayed effort. + +For local feedback, hold `microBridge.refresh()` after it captures the current `localSnapshotGeneration`, return `{ outcome: "applied", reasoningEffort: "xhigh" }` from the command, and prove the controller advances the generation, patches the local snapshot, and renders `XHIGH` without waiting for the poll. Release the held refresh with an older effort and prove both snapshot and dial remain `xhigh`. Also cover remote current-generation parity, rapid ordered detents, `ULTRA OFF`, and failure/no-confirmation cases. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run: + +```bash +npx tsx --test --test-name-pattern='reasoning feedback|reasoning outcome|pre-command reasoning' test/relay.test.ts +npx tsx --test --test-name-pattern='confirmed reasoning feedback|pre-command reasoning|Reasoning dial' test/controller-dial.test.ts +``` + +Expected: FAIL because protocol v1 has no feedback opt-in/result field, the relay does not place a forced post-command snapshot barrier before the result, and the controller neither invalidates an older local refresh nor commits/renders confirmed effort. + +- [ ] **Step 3: Implement additive negotiation, ordered relay commit, and local invalidation** + +Keep protocol version 1 and add the explicit `reasoning-feedback` ready capability. Permit the exact `includeReasoningFeedback: true` command key only on reasoning commands. The client adds it only when the current ready connection advertises the capability; otherwise it sends the legacy command shape. Keep `reasoning-policy` gating independent and unchanged. Record command kind, feedback opt-in, ready host ID, and connection generation in the pending entry. + +The server may serialize `reasoningEffort` only when the reasoning command opted in and its structured execution returned a safe confirmed effort; opted-in success without an exact outcome/effort fails rather than emitting a partial feedback result. When the flag is absent, strip the effort and emit the legacy-compatible result shape. On opted-in success, call and await `publishSnapshot(undefined, true)` before `sendResult`. This reuses `currentSnapshotMessage(true)`: it captures and awaits any prior `snapshotInFlight`, ignores only that prior read's rejection, performs a new `control.refresh()`, broadcasts the forced fresh snapshot, and then allows the result send. Since an already-awaiting older publisher sends first and all frames use the same WebSocket, FIFO order is old snapshot, fresh snapshot, result. + +On the client, accept and apply effort only for the correlated opted-in reasoning request while its recorded generation and host still match the current ready connection and retained snapshot. Patch only `snapshot.snapshot.reasoningEffort`; preserve the rest of the snapshot and `observedAt`. Exact parsing must reject feedback fields in every uncorrelated shape. + +For a local confirmed result, synchronously increment `localSnapshotGeneration` before immutably replacing `localSnapshot.snapshot.reasoningEffort`, with no `await` between invalidation and replacement. This makes any `refreshOnce()` that captured the old generation return before committing. Immediately call the existing registration-safe dial renderer. Remote commands rely on the server barrier and current-generation client patch before the controller renders. Do nothing on missing effort, command error, stale host/generation, or malformed result, and preserve `ULTRA OFF` notice serialization. + +- [ ] **Step 4: Run controller, relay, bridge, and type checks** + +Run: + +```bash +npx tsx --test test/controller-dial.test.ts test/relay.test.ts test/micro-bridge.test.ts +npm run check +``` + +Expected: every focused test and the full TypeScript check pass in this commit. The immediate-feedback tests do not require the 1.2-second timer; held pre-command local/remote reads cannot regress confirmed feedback; and both rolling-upgrade directions retain their legacy shapes. + +- [ ] **Step 5: Commit the slice** + +```bash +git add src/relay-protocol.ts src/codex-relay-server.ts src/codex-relay-client.ts src/controller.ts test/relay.test.ts test/controller-dial.test.ts +# If Swift decoding changed, also stage the two conditional iOS files listed above. +git commit -m "fix: render confirmed reasoning feedback immediately" +``` + +### Task 4: Document, review, verify, merge, and install + +**Files:** +- Modify: `docs/STREAM_DECK_PLUS.md` +- Test: `test/dial-action.test.ts` + +- [ ] **Step 1: Add the test-only failing guide assertion** + +In `test/dial-action.test.ts`, add assertions requiring the guide to state that Reasoning feedback updates from Codex's confirmed level immediately, without waiting for the background poll, and that background polling only reconciles it. Do not edit `docs/STREAM_DECK_PLUS.md` in this step, and do not assert optimistic or zero-latency behavior. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `npx tsx --test --test-name-pattern='Stream Deck Plus guide' test/dial-action.test.ts` + +Expected: FAIL because `docs/STREAM_DECK_PLUS.md` does not yet contain the immediate-confirmed-feedback and background-reconciliation wording. + +- [ ] **Step 3: Document the confirmed feedback contract** + +Update `docs/STREAM_DECK_PLUS.md` to state that a successful Reasoning detent redraws from Codex's confirmed effort immediately without waiting for the scheduled poll, while the background poll continues as reconciliation. Do not claim optimistic feedback or zero latency. + +- [ ] **Step 4: Rerun the focused test and verify GREEN** + +Run: `npx tsx --test --test-name-pattern='Stream Deck Plus guide' test/dial-action.test.ts` + +Expected: PASS with the new guide assertion satisfied. + +- [ ] **Step 5: Run full verification** + +Run: + +```bash +npx tsx --test test/dial-action.test.ts +npm run check +npm test +npm run validate +npm run audit:release +git diff --check +git status --short +``` + +Expected: 0 failures; only the intentional platform skip; Stream Deck validation and all release roots pass. Restore only the two known validator-regenerated plugin PNGs if needed. + +- [ ] **Step 6: Run a live read-only metadata probe** + +Against the current Codex main renderer, verify without executing a command that the unique visible trigger reports effort `high`; its ordinary DOM contains hidden measurement label `5.3 Codex Spark` and multiple visible leaf candidates including effort `high` and model label `5.6 Sol`; strict normalization ignores unmatched effort text and identifies exactly one validated `models/list` record with `displayName` `GPT-5.6-Sol` and model ID `gpt-5.6-sol`; and the result preserves that same record's supported reasoning effort order. Record the observed order from the live record rather than asserting an unverified hardcoded sequence. + +- [ ] **Step 7: Request independent final review** + +Review the complete diff for ordinary-DOM visibility/measurement filtering, strict label normalization, unique catalog matching, descriptor and traversal safety, absence of React model authorization, protected command isolation, confirmation honesty, relay generation handling, registration-safe immediate redraw, background-poll reconciliation, rapid-detent serialization, notice races, and packaging. Fix every Critical or Important finding with a separate RED/GREEN correction commit and re-review. + +- [ ] **Step 8: Commit docs, merge, and verify main** + +```bash +git add docs/STREAM_DECK_PLUS.md test/dial-action.test.ts +git commit -m "docs: explain immediate reasoning feedback" +git switch main +git merge --ff-only fix/reasoning-dial-feedback-latency +npm run check +npm test +``` + +- [ ] **Step 9: Back up and install** + +Quit Stream Deck, create timestamped backups of the installed `com.simeo.codex-deck.sdPlugin` and active Stream Deck+ profile, atomically replace the plugin with `dist/com.simeo.codex-deck.sdPlugin`, relaunch Stream Deck, and verify matching bundle hashes, `local=ready`, layout synchronization, explicit `includeUltraReasoning:false`, and a clean `main` worktree. diff --git a/docs/superpowers/plans/2026-08-10-reasoning-ultra-toggle.md b/docs/superpowers/plans/2026-08-10-reasoning-ultra-toggle.md new file mode 100644 index 0000000..672a128 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-reasoning-ultra-toggle.md @@ -0,0 +1,409 @@ +# Reasoning Dial Ultra Toggle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every Codex Dial its own `Include Ultra` setting and prevent a restricted Reasoning dial from issuing the command that would enter Ultra, locally or through the authenticated relay. + +**Architecture:** Persist a strict boolean in the existing per-action dial settings, mirror it exactly in the property inspector, and carry it only on dial-originated reasoning commands. The target Codex renderer will atomically read the unique visible composer’s current model and effort, join that model to Codex’s live ordered model metadata, and either run one allow-listed reasoning command or return a typed `blocked-ultra` result. Typed relay results carry that non-error outcome back to the Stream Deck host so the dial can show a short `ULTRA OFF` notice without an alert or optimistic state change. + +**Tech Stack:** TypeScript, Node.js test runner through `tsx --test`, Elgato Stream Deck SDK, CDP renderer bridge, React Query cache inspection, WebSocket relay, Stream Deck property-inspector HTML/JavaScript. + +--- + +## File Map + +- `src/dial-types.ts` — add the required per-dial boolean. +- `src/dial-domain.ts` — emit and normalize the field for every preset. +- `static/property-inspector/codex-dial.html` — expose, persist, and normalize `Include Ultra`. +- `src/types.ts` — define the typed reasoning policy/result shared by controller, bridge, and relay. +- `src/codex-micro-renderer-bridge.ts` — discover the active model’s ordered efforts and enforce the guard in the renderer. +- `src/relay-protocol.ts` — require a literal policy boolean and validate the typed result. +- `src/codex-relay-server.ts` — execute and return the renderer’s reasoning result. +- `src/codex-relay-client.ts` — resolve pending commands with the typed result. +- `src/controller.ts` — pass the dial setting, preserve unrestricted keypad behavior, and render `ULTRA OFF`. +- `test/dial-domain.test.ts` — settings and preset migration coverage. +- `test/dial-action.test.ts` — property-inspector/runtime parity and control behavior. +- `test/micro-bridge.test.ts` — live state discovery and guard behavior. +- `test/relay.test.ts` — relay trust-boundary and result round-trip coverage. +- `test/controller-dial.test.ts` — local/remote policy propagation and transient feedback. +- `docs/STREAM_DECK_PLUS.md` — user-facing checkbox and safety behavior. + +## Task 1: Persist the Per-Dial Setting and Expose It in the Inspector + +**Files:** +- Modify: `src/dial-types.ts` +- Modify: `src/dial-domain.ts` +- Modify: `static/property-inspector/codex-dial.html` +- Test: `test/dial-domain.test.ts` +- Test: `test/dial-action.test.ts` + +- [ ] **Step 1: Write failing runtime normalization tests** + +Add assertions beside the existing preset and normalization cases in `test/dial-domain.test.ts`: + +```ts +test("dial settings default Include Ultra off and preserve only literal booleans", () => { + assert.equal(expandDialPreset("reasoning").includeUltraReasoning, false); + for (const preset of DIAL_PRESETS) { + assert.equal(typeof expandDialPreset(preset).includeUltraReasoning, "boolean"); + } + + const base = expandDialPreset("reasoning"); + assert.equal(normalizeDialSettings({ ...base, includeUltraReasoning: true }).includeUltraReasoning, true); + assert.equal(normalizeDialSettings({ ...base, includeUltraReasoning: false }).includeUltraReasoning, false); + assert.equal(normalizeDialSettings({ ...base }).includeUltraReasoning, false); + for (const malformed of [null, 0, 1, "true", {}, []]) { + assert.equal(normalizeDialSettings({ ...base, includeUltraReasoning: malformed }).includeUltraReasoning, false); + } +}); +``` + +- [ ] **Step 2: Write failing property-inspector parity tests** + +Extend `test/dial-action.test.ts` so its DOM-lite inspector harness proves: + +```ts +assert.equal(inspectorNormalize({ ...reasoning, includeUltraReasoning: true }).includeUltraReasoning, true); +assert.equal(inspectorNormalize({ ...reasoning, includeUltraReasoning: "true" }).includeUltraReasoning, false); +assert.equal(readForm().includeUltraReasoning, includeUltraCheckbox.checked); +``` + +Also assert the checkbox label/help text, that applying incoming settings updates it, and that changing unrelated fields retains it in the complete `setSettings` payload. + +- [ ] **Step 3: Run the focused tests and verify RED** + +Run: + +```bash +npx tsx --test --test-name-pattern='Include Ultra|property inspector' test/dial-domain.test.ts test/dial-action.test.ts +``` + +Expected: FAIL because the settings field and checkbox do not exist. + +- [ ] **Step 4: Implement the strict shared settings contract** + +Add to `CodexDialSettings` in `src/dial-types.ts`: + +```ts +includeUltraReasoning: boolean; +``` + +Make `pairedPreset` and `selectorPreset` in `src/dial-domain.ts` emit `includeUltraReasoning: false`, and make `normalizeDialSettings` use: + +```ts +includeUltraReasoning: hasOwn(input, "includeUltraReasoning") && + typeof input.includeUltraReasoning === "boolean" + ? input.includeUltraReasoning + : false, +``` + +Do not infer true from preset, binding, truthiness, or a string. + +- [ ] **Step 5: Implement inspector parity** + +Add this control to `static/property-inspector/codex-dial.html` near the paired reasoning controls: + +```html + +``` + +Add `includeUltraReasoning: false` to every `PRESETS` entry. In `normalizedSettings`, preserve only a literal boolean. In `applySettings`, set `.checked`; in `readForm`, always return the boolean. Show the row when either paired rotation binding is `reasoning.increase`, and attach the existing persist handler so changes immediately send the full settings object. + +- [ ] **Step 6: Run focused tests and verify GREEN** + +Run: + +```bash +npx tsx --test test/dial-domain.test.ts test/dial-action.test.ts +npm run check +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 7: Commit the settings slice** + +```bash +git add src/dial-types.ts src/dial-domain.ts static/property-inspector/codex-dial.html test/dial-domain.test.ts test/dial-action.test.ts +git commit -m "feat: configure Ultra per reasoning dial" +``` + +## Task 2: Enforce the Guard Against Live Renderer State + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/codex-micro-renderer-bridge.ts` +- Test: `test/micro-bridge.test.ts` + +- [ ] **Step 1: Write failing pure decision tests** + +Export a testable helper contract from the bridge and add: + +```ts +test("restricted reasoning blocks only the live step into Ultra", () => { + const efforts = ["low", "medium", "high", "xhigh", "max", "ultra"]; + assert.equal(reasoningAdjustmentDecision("increase", false, "max", efforts), "blocked-ultra"); + assert.equal(reasoningAdjustmentDecision("increase", false, "xhigh", efforts), "apply"); + assert.equal(reasoningAdjustmentDecision("increase", true, "max", efforts), "apply"); + assert.equal(reasoningAdjustmentDecision("decrease", false, "ultra", efforts), "apply"); + assert.equal(reasoningAdjustmentDecision("increase", false, "max", []), "unavailable"); + assert.equal(reasoningAdjustmentDecision("increase", false, undefined, efforts), "unavailable"); +}); +``` + +Cover duplicate/malformed efforts by requiring an ordered, unique array of recognized nonblank strings with `ultra` appearing at most once. + +- [ ] **Step 2: Write failing active-composer discovery tests** + +In `test/micro-bridge.test.ts`, use fake visible reasoning triggers and fake React element props to prove the discovery function: + +- accepts exactly one visible semantic reasoning trigger; +- reads effort from `data-selected-reasoning-effort`; +- reads the active model ID only from the trigger’s current `selectedValue` React subtree, not hidden measurement children; +- rejects multiple visible triggers, missing model IDs, and disagreement between candidates; +- joins that model ID to a `models/list` query record and preserves the server-provided `supportedReasoningEfforts` order. + +- [ ] **Step 3: Write failing bridge command tests** + +Extend the existing dedicated reasoning command tests so a fake `evaluate` proves: + +```ts +assert.equal(await bridge.adjustReasoning("increase", { includeUltra: false }), "blocked-ultra"); +assert.deepEqual(commands, []); +assert.equal(await bridge.adjustReasoning("increase", { includeUltra: true }), "applied"); +assert.deepEqual(commands, ["composer.increaseReasoningEffort"]); +``` + +Also cover a lower restricted increase, decrease from Ultra, and missing live metadata. Missing metadata must issue no command; unrestricted callers may retain the existing direct command behavior. + +- [ ] **Step 4: Run focused tests and verify RED** + +```bash +npx tsx --test --test-name-pattern='reasoning.*Ultra|active composer reasoning|dedicated reasoning' test/micro-bridge.test.ts +``` + +Expected: FAIL because the policy, decision helper, and renderer discovery do not exist. + +- [ ] **Step 5: Add shared result types** + +In `src/types.ts`, add: + +```ts +export type ReasoningAdjustmentPolicy = { includeUltra: boolean }; +export type ReasoningAdjustmentResult = "applied" | "blocked-ultra"; +``` + +Keep the bridge’s default unrestricted for existing callers: + +```ts +async adjustReasoning( + direction: ReasoningAdjustment, + policy: ReasoningAdjustmentPolicy = { includeUltra: true } +): Promise +``` + +- [ ] **Step 6: Implement atomic live-state enforcement** + +In `src/codex-micro-renderer-bridge.ts`: + +1. Reuse the verified selector `[data-codex-intelligence-trigger="true"][data-composer-navigation-target="reasoning"]` and existing visibility rules. +2. From that unique element’s `__reactProps$...` value, traverse only the current `selectedValue` subtree and accept exactly one bounded `props.model` string. Explicitly ignore measurement/`aria-hidden` branches so stale width-measurement models cannot win. +3. Traverse the React root’s fibers for a query client, using the same bounded 30,000-fiber pattern as snapshots. +4. Find the current `models/list` query data, match the active model ID exactly, and normalize its ordered `supportedReasoningEfforts[].reasoningEffort` values. +5. In the same `Runtime.evaluate`, call `reasoningAdjustmentDecision`. Return `blocked-ultra` without resolving/importing the command runner when restricted. Throw `Codex reasoning metadata is unavailable.` for a restricted increase whose state is ambiguous. Otherwise invoke exactly one member of `REASONING_COMMANDS` and return `applied`. + +Do not read or write `enabled-reasoning-efforts`, `show-ultra-in-model-picker-slider`, `model_picker_persists_ultra_effort`, or any dialog element. + +- [ ] **Step 7: Run focused and structural checks** + +```bash +npx tsx --test test/micro-bridge.test.ts +npm run check +git diff --check +``` + +Expected: all pass, including serialized-helper dependency tests. + +- [ ] **Step 8: Commit the renderer slice** + +```bash +git add src/types.ts src/codex-micro-renderer-bridge.ts test/micro-bridge.test.ts +git commit -m "feat: stop reasoning dial before Ultra" +``` + +## Task 3: Carry Typed Results Through Controller and Relay + +**Files:** +- Modify: `src/relay-protocol.ts` +- Modify: `src/codex-relay-server.ts` +- Modify: `src/codex-relay-client.ts` +- Modify: `src/controller.ts` +- Modify: `test/relay.test.ts` +- Modify: `test/controller-dial.test.ts` + +- [ ] **Step 1: Write failing relay parser tests** + +Require reasoning commands to carry an explicit literal boolean: + +```ts +assert.deepEqual(parseRelayCommand({ + kind: "reasoning", direction: "increase", includeUltra: false +}), { kind: "reasoning", direction: "increase", includeUltra: false }); +for (const includeUltra of [undefined, null, 0, "false", {}]) { + assert.equal(parseRelayCommand({ kind: "reasoning", direction: "increase", includeUltra }), null); +} +``` + +Add server-message tests accepting only `outcome: "applied" | "blocked-ultra"` on a successful result and rejecting unknown strings, outcomes on failed results, or extra malformed fields. + +- [ ] **Step 2: Write failing local and remote controller tests** + +In `test/controller-dial.test.ts`, prove: + +- a dial with `includeUltraReasoning: false` calls the local bridge with `{ includeUltra: false }`; +- a true setting carries true; +- the public keypad path `controller.adjustReasoning("increase")` remains unrestricted; +- the remote command contains the explicit boolean; +- a local or remote `blocked-ultra` result sets feedback to title `REASONING`, value `ULTRA OFF`, detail `ENABLE IN DIAL SETTINGS`, with no `showAlert` call; +- after the short timer, authoritative reasoning feedback replaces the notice; +- disposed/replaced dial registrations cannot receive stale notice restoration. + +- [ ] **Step 3: Run focused tests and verify RED** + +```bash +npx tsx --test --test-name-pattern='reasoning.*includeUltra|blocked-ultra|ULTRA OFF' test/relay.test.ts test/controller-dial.test.ts +``` + +Expected: FAIL because relay commands/results and controller notices are still void/untyped. + +- [ ] **Step 4: Implement the strict relay round trip** + +Change the reasoning `RelayCommand` to: + +```ts +{ kind: "reasoning"; direction: ReasoningAdjustment; includeUltra: boolean } +``` + +Add an optional successful result outcome: + +```ts +type RelayResultMessage = { + type: "result"; + protocol: 1; + requestId: string; + ok: boolean; + outcome?: ReasoningAdjustmentResult; + error?: string; +}; +``` + +Have `executeRelayCommand` return the bridge result for reasoning, `sendResult` include the validated outcome, and `CodexRelayClient.send` resolve with it. Preserve the existing post-command snapshot publication ordering. Do not encode a block as an error. + +- [ ] **Step 5: Implement controller propagation and notice** + +Keep public non-dial reasoning unrestricted by sending `includeUltra: true`. For `reasoning.increase` and `reasoning.decrease` dial bindings, pass `registration.settings.includeUltraReasoning` both locally and remotely and inspect the returned outcome. + +Generalize the existing registration-scoped temporary success suppression into a temporary notice helper, then add: + +```ts +await action.setFeedback({ + title: "REASONING", + value: "ULTRA OFF", + detail: "ENABLE IN DIAL SETTINGS", + indicator: 100, + accent: { value: 100, bar_fill_c: "#FF9A3D" } +}); +``` + +Use a 1,200 ms timer, clear it on settings changes/disposal, never call `showAlert` for `blocked-ultra`, clear `lastFeedback`, and re-render from the latest snapshot when the notice ends. No optimistic reasoning value is stored. + +- [ ] **Step 6: Run focused and full slice checks** + +```bash +npx tsx --test test/relay.test.ts test/controller-dial.test.ts +npm run check +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 7: Commit the command-flow slice** + +```bash +git add src/relay-protocol.ts src/codex-relay-server.ts src/codex-relay-client.ts src/controller.ts test/relay.test.ts test/controller-dial.test.ts +git commit -m "feat: relay Ultra guard results" +``` + +## Task 4: Document, Validate, Install, and Physically Verify + +**Files:** +- Modify: `docs/STREAM_DECK_PLUS.md` +- Modify: installed plugin package outside Git after all repository checks pass +- Modify: installed Stream Deck + profile outside Git after backup + +- [ ] **Step 1: Update the Stream Deck + guide** + +Document: + +- `Include Ultra` is per knob and defaults off; +- off means clockwise stops before Ultra and briefly shows `ULTRA OFF`; +- on restores Ultra and Codex may show its own Full-access confirmation; +- manual Codex selection and keypad Reasoning Up remain unchanged; +- the plugin never confirms or dismisses the Codex safety dialog. + +- [ ] **Step 2: Run fresh complete verification** + +```bash +npm run check +npm test +npm run validate +npm run audit:release +git diff --check +git status --short +``` + +Expected: typecheck passes; all non-platform-skipped tests pass; Stream Deck validation passes; all release roots pass audit; diff check passes. If `npm run validate` regenerates tracked plugin PNG bytes, restore only those known generated images and rerun `git status --short` before committing. + +- [ ] **Step 3: Commit documentation** + +```bash +git add docs/STREAM_DECK_PLUS.md +git commit -m "docs: explain the Reasoning Ultra guard" +``` + +- [ ] **Step 4: Request independent code and spec review** + +Use `superpowers:requesting-code-review`. Address every Critical/Important finding with a separate red/green correction commit, then rerun the complete verification commands. + +- [ ] **Step 5: Merge to main only after approval** + +Use `superpowers:finishing-a-development-branch`; merge the reviewed branch into `main` without discarding unrelated work. Verify `git log -1 --oneline` and a clean repository worktree. + +- [ ] **Step 6: Back up and install the verified macOS build** + +Use exact local variables without writing a user-specific home path into release artifacts: + +```bash +PLUGIN_ID='com.simeo.codex-deck.sdPlugin' +INSTALLED_PLUGIN="$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/$PLUGIN_ID" +PROFILE_ROOT="$HOME/Library/Application Support/com.elgato.StreamDeck/ProfilesV3/F8241CEB-236C-467E-9A9A-F83FEC4307A4.sdProfile" +PROFILE_MANIFEST="$PROFILE_ROOT/Profiles/11F62414-1CB7-4E97-992F-3B568CD1382F/manifest.json" +``` + +Quit Elgato Stream Deck, back up `INSTALLED_PLUGIN` and `PROFILE_ROOT` under the workspace `work/backups/` directory, replace the installed plugin with the validated `dist/$PLUGIN_ID`, and update only Encoder `0,0` in `PROFILE_MANIFEST` so it contains: + +```json +"includeUltraReasoning": false +``` + +Reopen `/Applications/Elgato Stream Deck.app`, confirm the Codex Deck plugin process is running, and re-read the profile JSON to prove the setting persisted. + +- [ ] **Step 7: Perform physical Stream Deck + QA** + +With the checkbox off, rotate through every lower level and prove the next clockwise detent at the ceiling shows `ULTRA OFF`, sends no command, and opens no Codex dialog. Turn back down and confirm decrease works. Check the box in the property inspector, retry, and confirm Codex owns any Ultra confirmation. Restart Stream Deck and Codex and confirm the per-knob setting persists. diff --git a/docs/superpowers/plans/2026-08-10-usage-reset-countdown.md b/docs/superpowers/plans/2026-08-10-usage-reset-countdown.md new file mode 100644 index 0000000..215140f --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-usage-reset-countdown.md @@ -0,0 +1,118 @@ +# Human-Readable Usage Reset Countdown Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show weekly and other long usage reset intervals as compact days, hours, and minutes, for example `5D 5H 48M`, without changing timestamps, rounding, or dial layout. + +**Architecture:** Keep the existing pure `resetCountdown` boundary and minute-ceiling behavior. Decompose the already-ceiled total minutes into days, hours, and minutes, omit zero units, and let all usage modes continue consuming that one formatter. + +**Tech Stack:** TypeScript, Node.js test runner through `tsx --test`, Elgato Stream Deck SDK dial feedback. + +--- + +## File Map + +- `src/dial-domain.ts` — decompose remaining minutes into compact day/hour/minute units. +- `test/dial-domain.test.ts` — cover boundary, rounding, unavailable, and exact-string behavior. +- `docs/STREAM_DECK_PLUS.md` — document the human-readable countdown. + +## Task 1: Format Long Reset Intervals with Days + +**Files:** +- Modify: `src/dial-domain.ts` +- Test: `test/dial-domain.test.ts` + +- [ ] **Step 1: Write failing exact-string tests through public feedback** + +Add a table-driven test beside existing usage feedback cases. Build a weekly `DialRuntimeView`, call `deriveDialFeedback`, and assert its `detail`: + +```ts +const cases = [ + { minutes: 48, expected: "RESETS IN 48M" }, + { minutes: 5 * 60 + 48, expected: "RESETS IN 5H 48M" }, + { minutes: 24 * 60, expected: "RESETS IN 1D" }, + { minutes: 24 * 60 + 1, expected: "RESETS IN 1D 1M" }, + { minutes: 125 * 60 + 48, expected: "RESETS IN 5D 5H 48M" }, + { minutes: 7 * 24 * 60, expected: "RESETS IN 7D" }, + { minutes: 0, expected: "RESETS IN 0M" } +]; +``` + +Keep an additional sub-minute case such as `60_001` milliseconds to prove `Math.ceil` still reports `2M`, plus non-finite/missing reset timestamps that remain `RESET UNAVAILABLE`. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +npx tsx --test --test-name-pattern='usage reset countdown.*days' test/dial-domain.test.ts +``` + +Expected: FAIL because 125 hours still renders as `125H 48M`. + +- [ ] **Step 3: Implement minimal day decomposition** + +Replace only the formatting tail of `resetCountdown` in `src/dial-domain.ts`: + +```ts +const minutes = Math.max(0, Math.ceil((resetsAt - now) / 60_000)); +const days = Math.floor(minutes / (24 * 60)); +const hours = Math.floor((minutes % (24 * 60)) / 60); +const remainder = minutes % 60; +const units = [ + days > 0 ? `${days}D` : "", + hours > 0 ? `${hours}H` : "", + remainder > 0 || (days === 0 && hours === 0) ? `${remainder}M` : "" +].filter(Boolean); +return `RESETS IN ${units.join(" ")}`; +``` + +Do not change `resetsAt`, `now`, `Math.ceil`, `RESET UNAVAILABLE`, usage mode selection, or feedback field bounds. + +- [ ] **Step 4: Run focused checks and verify GREEN** + +```bash +npx tsx --test test/dial-domain.test.ts +npm run check +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 5: Commit the formatter slice** + +```bash +git add src/dial-domain.ts test/dial-domain.test.ts +git commit -m "feat: show usage reset days" +``` + +## Task 2: Document and Verify the Countdown + +**Files:** +- Modify: `docs/STREAM_DECK_PLUS.md` + +- [ ] **Step 1: Update the guide** + +State that reset detail uses compact day/hour/minute units and include the example `RESETS IN 5D 5H 48M`. Confirm the same formatter applies to Auto, 5-hour, and Weekly modes. + +- [ ] **Step 2: Run fresh complete verification** + +```bash +npm run check +npm test +npm run validate +npm run audit:release +git diff --check +git status --short +``` + +Expected: all non-platform-skipped tests pass, Stream Deck validation passes, release audit passes, and no unexpected generated file remains modified. If validation regenerates only the known tracked plugin PNGs, restore those two generated files and prove the remaining status is scoped. + +- [ ] **Step 3: Commit the documentation slice** + +```bash +git add docs/STREAM_DECK_PLUS.md +git commit -m "docs: explain usage reset countdowns" +``` + +- [ ] **Step 4: Include the countdown in final review and install** + +Request independent review together with the Ultra feature, merge only after approval, and install the same validated build. On Stream Deck +, select Weekly and confirm a 125-hour-and-48-minute interval fits as `RESETS IN 5D 5H 48M` without truncation; also inspect a sub-day interval to confirm the original hour/minute style remains. diff --git a/docs/superpowers/plans/2026-08-11-model-presets-dial.md b/docs/superpowers/plans/2026-08-11-model-presets-dial.md new file mode 100644 index 0000000..51b2f89 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-model-presets-dial.md @@ -0,0 +1,570 @@ +# Model Presets Dial Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a per-knob Stream Deck + Model Presets dial that immediately and truthfully switches among configured Codex model/reasoning pairs. + +**Architecture:** Extend the existing strict dial-settings and snapshot contracts with a bounded model catalog and a dedicated model-presets rotation. Invoke Codex's native paired `onSelectModel(modelId, effort)` callback once under the existing renderer-global reasoning guard, confirm the re-rendered pair, then propagate confirmed local or capability-negotiated relay feedback with generation fencing. + +**Tech Stack:** TypeScript 5.9, Node.js 20+, `@elgato/streamdeck` 2.1, inline Stream Deck property-inspector HTML/JavaScript, WebSocket relay protocol v1, Node test runner through `tsx`. + +--- + +## File structure + +- `src/dial-types.ts`: versioned dial settings, Model Presets entry/rotation/feedback types, transient runtime catalog and switching state. +- `src/dial-domain.ts`: strict settings migration/normalization, valid-preset resolution, direction-only rotation planning, and truthful LCD feedback. +- `src/types.ts`: normalized model catalog and confirmed composite request/result types shared by renderer, controller, and relay. +- `src/codex-micro-renderer-bridge.ts`: descriptor-safe catalog snapshot extraction, native paired model selector resolution, serialization, invocation, and confirmation. +- `src/dial-action.ts`: Stream Deck SDK property-inspector request forwarding. +- `src/controller.ts`: target-host catalog responses, direction-only dial queue execution, local generation fencing, transient switching/error state, and remote dispatch. +- `src/relay-protocol.ts`: strict capability, command, result, and snapshot wire types/parsers. +- `src/codex-relay-server.ts`: composite command execution and forced-snapshot-before-result ordering. +- `src/codex-relay-client.ts`: capability admission, pending-command identity fencing, and immutable confirmed pair patching. +- `static/property-inspector/codex-dial.html`: live per-knob model/effort editor with add/remove/drag/keyboard reorder and reconnect safety. +- `test/dial-domain.test.ts`: settings, migration, resolver, wrap, unavailable, Ultra, and feedback tests. +- `test/micro-bridge.test.ts`: catalog extraction, hostile renderer shapes, paired callback, confirmation, concurrency, and timeout tests. +- `test/dial-action.test.ts`: adapter and property-inspector behavior tests. +- `test/controller-dial.test.ts`: catalog responses, queue timing, switching/error feedback, local patching, and lifecycle tests. +- `test/relay.test.ts`: protocol strictness, capability compatibility, FIFO ordering, and identity fencing tests. +- `docs/STREAM_DECK_PLUS.md`, `README.md`, `CHANGELOG.md`: user-facing setup, behavior, compatibility, and release notes. + +### Task 1: Versioned Model Presets settings and pure dial domain + +**Files:** +- Modify: `src/dial-types.ts` +- Modify: `src/dial-domain.ts` +- Test: `test/dial-domain.test.ts` + +- [ ] **Step 1: Add failing version-2 migration and hostile-settings tests** + +Add fixtures that assert all existing version-1 presets normalize to version 2 without behavior changes, while Model Presets accepts only an exact bounded unique list: + +```ts +const modelPresets = { + version: 2, + preset: "model-presets", + customized: false, + includeUltraReasoning: false, + rotation: { kind: "model-presets" }, + press: "none", + touchTap: "keycap.FAST", + feedback: "model-presets", + modelPresets: [ + { modelId: "gpt-5.6-sol", reasoningEffort: "high" }, + { modelId: "gpt-5.6-sol", reasoningEffort: "medium" }, + { modelId: "gpt-5.6-terra", reasoningEffort: "medium" } + ] +}; +assert.deepEqual(normalizeDialSettings(modelPresets), modelPresets); +assert.equal(normalizeDialSettings(version1Reasoning).version, 2); +``` + +Cover zero, one, and twelve entries; thirteen entries; duplicates; inherited fields; custom prototypes; symbols; accessors with zero getter calls; unsafe identifiers; and extra nested keys. A malformed v2 payload must equal `expandDialPreset("model-presets")`, never a partial merge. + +- [ ] **Step 2: Run the domain tests and capture RED** + +Run: + +```bash +npx tsx --test --test-name-pattern "model preset|version 2|migration" test/dial-domain.test.ts +``` + +Expected: failures because `model-presets`, settings version 2, and entry normalization do not exist. + +- [ ] **Step 3: Define the discriminated settings and runtime types** + +Refactor the current settings interface into a version-neutral base plus exact discriminated variants. Keep `JsonObject` index compatibility explicitly: + +```ts +export interface ModelPresetEntry extends JsonObject { + modelId: string; + reasoningEffort: string; +} +export interface ModelPresetRotation extends JsonObject { kind: "model-presets" } + +export interface CodexDialSettingsBase extends JsonObject { + customized: boolean; + includeUltraReasoning: boolean; + press: DialBindingId; + touchTap: DialBindingId; + staticLabel?: string; +} + +export interface ExistingDialSettingsV2 extends CodexDialSettingsBase { + version: 2; + preset: LegacyDialPreset; + rotation: PairedDialRotation | SelectorDialRotation; + feedback: LegacyDialFeedbackMode; +} + +export interface ModelPresetsDialSettings extends CodexDialSettingsBase { + version: 2; + preset: "model-presets"; + rotation: ModelPresetRotation; + feedback: "model-presets"; + modelPresets: ModelPresetEntry[]; +} + +export type CodexDialSettings = ExistingDialSettingsV2 | ModelPresetsDialSettings; +``` + +Preserve a private/exported `CodexDialSettingsV1` input type for migration, with legacy preset/rotation/feedback discriminants only. Add `model-presets` to the public preset and feedback constants while defining `LegacyDialPreset` and `LegacyDialFeedbackMode` as their exclusions. Add transient `modelCatalog`, `activeModelId`, `activeModelDisplayName`, and `modelPresetSwitching` fields to the runtime view/state rather than settings. + +- [ ] **Step 4: Implement strict migration, defaults, and normalization** + +Use own data descriptors before reading nested arrays. Preserve the current model-ID grammar and 128-character bound, and use `isSafeReasoningIdentifier` with its 64-character bound for efforts. Reject duplicates, require at most twelve entries, and emit complete v2 objects. `expandDialPreset("model-presets")` returns the approved press/touch/feedback defaults and a syntactically empty list; live preferred-pair seeding belongs to the inspector after authoritative catalog receipt. + +- [ ] **Step 5: Add failing pure resolver, wrap, and feedback tests** + +Define tests around a pure API: + +```ts +resolveModelPresetDirection(settings, view, "clockwise") +resolveModelPresetDirection(settings, view, "counter-clockwise") +``` + +Assert exact active pair moves next/previous with wrapping, an unlisted pair selects first/last, catalog-proven invalid and Ultra-disabled entries are skipped, missing catalog returns `{kind:"unavailable"}`, and authoritative zero-valid returns `{kind:"empty"}`. Assert feedback for confirmed listed, unlisted, switching, empty, unavailable, and unhealthy states. + +- [ ] **Step 6: Run the pure tests and capture RED** + +Run the same focused command. Expected: resolver and feedback assertions fail because those branches are absent. + +- [ ] **Step 7: Implement the pure resolver and feedback** + +Return a direction plan, not a prematurely captured target: + +```ts +type ModelPresetResolution = + | { kind: "target"; entry: ModelPresetEntry; index: number; count: number } + | { kind: "empty" } + | { kind: "unavailable" }; +``` + +Keep `reduceDialRotation` from resolving Model Presets. The controller will enqueue one direction closure per detent. Render `SWITCHING…` from transient state and never display a target pair before confirmation. + +- [ ] **Step 8: Verify and commit Task 1** + +Run: + +```bash +npx tsx --test test/dial-domain.test.ts +npm run check +git diff --check +``` + +Expected: all pass. Commit only the three scoped files: + +```bash +git add src/dial-types.ts src/dial-domain.ts test/dial-domain.test.ts +git commit -m "feat: add model preset dial settings" +``` + +### Task 2: Authoritative full model catalog in snapshots + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/codex-micro-renderer-bridge.ts` +- Modify: `src/relay-protocol.ts` +- Test: `test/micro-bridge.test.ts` +- Test: `test/relay.test.ts` + +- [ ] **Step 1: Add failing catalog extraction and snapshot tests** + +Refactor the existing Sol/Terra fixtures to assert a complete result: + +```ts +{ + activeModelId: "gpt-5.6-sol", + activeModelDisplayName: "5.6 Sol", + reasoningEffort: "high", + modelCatalog: [ + { modelId: "gpt-5.6-sol", displayName: "5.6 Sol", supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "ultra"] }, + { modelId: "gpt-5.6-terra", displayName: "5.6 Terra", supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "ultra"] } + ] +} +``` + +Retain all current hidden measurement, effort-label, five-part query-key, duplicate-identical, conflicting-record, accessor, proxy, traversal-bound, and unsafe-scalar cases. Add relay snapshot parser cases for catalog maximum 32 entries, maximum 16 efforts per model, exact own keys, and a package payload below the existing 64 KiB WebSocket limit. + +- [ ] **Step 2: Run focused tests and capture RED** + +```bash +npx tsx --test --test-name-pattern "model catalog|active model|snapshot" test/micro-bridge.test.ts test/relay.test.ts +``` + +Expected: missing snapshot fields and catalog validation failures. + +- [ ] **Step 3: Define normalized shared catalog types** + +Add: + +```ts +export type CodexModelCatalogEntry = { + modelId: string; + displayName: string; + supportedReasoningEfforts: string[]; +}; +``` + +Extend `MicroSnapshot` with optional `activeModelId`, `activeModelDisplayName`, and `modelCatalog`. Bound model IDs to 128 characters with the existing safe grammar, reasoning identifiers to 64 characters, display names to 80 characters, entries to 32, and efforts per entry to 16. + +- [ ] **Step 4: Refactor catalog extraction without weakening authorization** + +Split the current single-match helper into: + +```ts +readReasoningModelCatalog(reactRoot): CodexModelCatalogEntry[] | undefined +matchActiveReasoningModel(visibleLabels, catalog): CodexModelCatalogEntry | undefined +``` + +Preserve query-client clone gating, exact two- or five-part query keys, exact own array shapes, record deduplication, and distinct-conflict rejection. Serialize these helpers into `SNAPSHOT_EXPRESSION`, derive the active record from the unique visible trigger, and emit all three fields together or omit all catalog/model fields when authority is unavailable. + +- [ ] **Step 5: Validate catalog fields at the relay boundary** + +Extend `isSnapshot` with descriptor-safe exact validation. Do not trim or coerce identifiers. Reject partial active-model pairs, unknown properties, duplicate model IDs, duplicate efforts, or active IDs absent from the catalog. + +- [ ] **Step 6: Verify and commit Task 2** + +```bash +npx tsx --test test/micro-bridge.test.ts test/relay.test.ts +npm run check +git diff --check +git add src/types.ts src/codex-micro-renderer-bridge.ts src/relay-protocol.ts test/micro-bridge.test.ts test/relay.test.ts +git commit -m "feat: expose the active Codex model catalog" +``` + +### Task 3: Native paired model-and-reasoning bridge operation + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/codex-micro-renderer-bridge.ts` +- Test: `test/micro-bridge.test.ts` + +- [ ] **Step 1: Capture the current callback seam without invoking it** + +Run a read-only CDP probe against the installed Codex renderer. It must locate the unique visible semantic reasoning trigger, walk at most 50 React ancestors through own data descriptors, and print only sanitized callback metadata: required own property names, primitive model/effort values, callback arities, normalized `Function.prototype.toString.call` shapes, and bounded catalog records. It must not call either callback. Record the verified 26.803.41515 fixture in the test file: + +```ts +{ + model: "gpt-5.6-sol", + reasoningEffort: "high", + onSelectModelSource: "(e,t)=>{ye(e,t)}", + onSelectReasoningEffortSource: "e=>{ye(_,e)}", + onSelectModelArity: 2, + onSelectReasoningEffortArity: 1 +} +``` + +If no healthy live renderer is available, use this already recorded fixture and defer only the repeated live probe to Task 7; do not change the predicate. + +- [ ] **Step 2: Add failing selector-resolution tests** + +Extend the guarded renderer harness with a unique semantic trigger whose bounded React ancestor owns data properties `model`, `reasoningEffort`, `models`, `onSelectModel`, and `onSelectReasoningEffort`. Assert that the resolver returns exactly one paired callback only when: + +```ts +onSelectModel.length === 2 +onSelectReasoningEffort.length === 1 +``` + +and both are tightly anchored direct forwarders associated with the same exact catalog. Normalize only insignificant whitespace, then require the two-argument wrapper to match `(a,b)=>{callee(a,b)}` and the one-argument wrapper to match `b=>{sameCallee(capturedModel,b)}` with the same safe callee identifier. Use `Function.prototype.toString.call(callback)`, not a callback-owned method. Reject native-code strings, extra statements, return expressions, `.call`/`.apply`, different callees, computed calls, async wrappers, and any ambiguity. Add zero-invocation rejection cases for accessors, proxies, duplicate ancestors, missing core props, wrong arity, non-forwarder source, unsupported pair, and hidden/multiple triggers. + +- [ ] **Step 3: Run selector tests and capture RED** + +```bash +npx tsx --test --test-name-pattern "model preset selector|paired model" test/micro-bridge.test.ts +``` + +Expected: `applyModelPreset` and selector helpers do not exist. + +- [ ] **Step 4: Define request/result types and the bridge method** + +```ts +export type ModelPresetRequest = { + modelId: string; + reasoningEffort: string; + includeUltra: boolean; +}; +export type ModelPresetExecution = { + modelId: string; + reasoningEffort: string; +}; +``` + +Add `CodexMicroRendererBridge.applyModelPreset(request): Promise`. + +- [ ] **Step 5: Implement descriptor-safe active component resolution** + +Starting from the exactly-one visible reasoning trigger, require one own `__reactFiber$*` property, walk at most 50 `.return` ancestors through own data descriptors, and accept exactly one qualifying memoized-props object. Validate the component's bounded `models` catalog independently against the authoritative query-client catalog. Never invoke getters, DOM clicks, keyboard events, focus methods, or Electron IPC. + +- [ ] **Step 6: Add failing invocation, confirmation, and concurrency tests** + +Prove one invocation receives the exact pair: + +```ts +assert.deepEqual(selectorCalls, [["gpt-5.6-terra", "medium"]]); +assert.deepEqual(result, { modelId: "gpt-5.6-terra", reasoningEffort: "medium" }); +``` + +Cover same-pair no-op with zero calls, microtask and macrotask re-render confirmation, timeout, partial model-only state, missing re-rendered seam, mixed concurrent `adjustReasoning`/`applyModelPreset`, a queued second preset, disconnect namespace rotation, and late old responses. Assert only one callback invocation per request. + +- [ ] **Step 7: Run invocation tests and capture RED** + +Run the focused test file. Expected: invocation/confirmation/concurrency cases fail. + +- [ ] **Step 8: Implement the guarded one-shot operation** + +Share `globalThis.__codexDeckReasoningGuardStates` and `evaluationNamespace` with ordinary Reasoning. Inside the lock: validate current authority and Ultra policy; return the current pair for an exact no-op; reserve uncertainty; invoke `onSelectModel(modelId, reasoningEffort)` once; re-resolve the entire seam on immediate, microtask, and bounded 8 ms macrotask polls; clear uncertainty only after exact pair confirmation. On failure retain uncertainty, return an internal sentinel so metadata refusal does not disconnect, and rotate the namespace only for transport/evaluation failures. + +- [ ] **Step 9: Verify and commit Task 3** + +```bash +npx tsx --test test/micro-bridge.test.ts +npm run check +git diff --check +git add src/types.ts src/codex-micro-renderer-bridge.ts test/micro-bridge.test.ts +git commit -m "feat: apply confirmed Codex model presets" +``` + +### Task 4: Live property-inspector catalog and per-knob editor + +**Files:** +- Modify: `src/dial-action.ts` +- Modify: `src/controller.ts` +- Modify: `static/property-inspector/codex-dial.html` +- Test: `test/dial-action.test.ts` +- Test: `test/controller-dial.test.ts` + +- [ ] **Step 1: Add failing action-channel and visible-inspector lifecycle tests** + +Add adapter tests for `onPropertyInspectorDidAppear` and `onSendToPlugin`. Only the exact plain request is accepted: + +```ts +{ kind: "request-model-catalog", requestGeneration: 7 } +``` + +The controller response is exact plain data containing a controller-assigned monotonic catalog revision, current host ID/platform, snapshot generation, active pair, and catalog. Add `onPropertyInspectorDidAppear` registration and `onPropertyInspectorDidDisappear` unregistration tests. While the same inspector remains open, switching the target host, reconnecting with a new identity/generation, or receiving a changed authoritative catalog must push a newer revision through that exact action. Accessors, extras, symbols, wrong contexts, stale action registrations, and unavailable authority produce an exact unavailable response without throwing. A reply arriving after disappear/reappear must not overwrite the newer registration. + +- [ ] **Step 2: Run adapter/controller tests and capture RED** + +```bash +npx tsx --test --test-name-pattern "model catalog|property inspector" test/dial-action.test.ts test/controller-dial.test.ts +``` + +Expected: missing SDK handlers and controller response method. + +- [ ] **Step 3: Implement the plugin-to-inspector request and push path** + +Import `PropertyInspectorDidAppearEvent`, `PropertyInspectorDidDisappearEvent`, and `SendToPluginEvent` in `src/dial-action.ts`. Delegate through the exact dial action, and use `ev.action.sendToPropertyInspector(payload)` so replies cannot leak to another visible inspector. In `DeckController`, keep a visible-inspector registration keyed by action ID and registration generation, dedupe the last sent catalog signature, and assign a monotonic catalog revision. Snapshot and clone only the current target host's normalized catalog and identity/generation. Push a new or unavailable catalog on target toggle, relay ready/disconnect/identity change, local or remote snapshot generation change, and initial appear/request. Dispose the registration on disappear or action replacement and catch every send failure. + +- [ ] **Step 4: Add failing inspector editor tests** + +Extend the existing DOM-lite harness for: + +- Model Presets option and panel; +- preferred-pair seeding only after authoritative catalog receipt; +- model dropdowns and model-specific effort dropdowns; +- Add, Remove, drag reorder, Move Up, and Move Down; +- twelve-row cap and duplicate prevention; +- Include Ultra filtering that preserves disabled Ultra rows; +- unavailable versus unknown rows; +- offline saved-row preservation; +- complete v2 `setSettings` payloads; +- stale socket, monotonic catalog revision, host switch without inspector reopen, disappear/reappear, and pre-open edit fencing. + +- [ ] **Step 5: Run inspector tests and capture RED** + +Run `test/dial-action.test.ts`. Expected: missing controls and message handling failures. + +- [ ] **Step 6: Implement the live editor** + +Add a dedicated panel and render rows from saved settings plus transient catalog state. Request catalog after registration, then accept controller pushes only on the current WebSocket when their catalog revision is strictly newer than the last accepted revision. Reset the accepted revision on reconnect; host identity and snapshot generation remain display/diagnostic fields, while the controller's monotonic revision supplies ordering. Persist stable IDs/efforts only. Implement HTML5 drag ordering plus the same operation through accessible buttons and labels. + +- [ ] **Step 7: Verify and commit Task 4** + +```bash +npx tsx --test test/dial-action.test.ts test/controller-dial.test.ts +npm run check +git diff --check +git add src/dial-action.ts src/controller.ts static/property-inspector/codex-dial.html test/dial-action.test.ts test/controller-dial.test.ts +git commit -m "feat: configure model presets per dial" +``` + +### Task 5: Local dial queue, switching feedback, and confirmed snapshot patch + +**Files:** +- Modify: `src/dial-types.ts` +- Modify: `src/dial-domain.ts` +- Modify: `src/controller.ts` +- Test: `test/dial-domain.test.ts` +- Test: `test/controller-dial.test.ts` + +- [ ] **Step 1: Add failing direction-only queue tests** + +Simulate three rapid clockwise detents from the first of three pairs while holding each bridge result. Assert operations begin in order and resolve targets only at execution time: second, third, first. Add counter-clockwise wrap, unlisted first/last, invalid-entry skip, full queue rejection, replacement/disposal, and target-host change before execution. + +- [ ] **Step 2: Run queue tests and capture RED** + +```bash +npx tsx --test --test-name-pattern "model preset|rapid detent|switching" test/controller-dial.test.ts test/dial-domain.test.ts +``` + +Expected: `rotateDial` has no model-presets branch and no composite dispatch. + +- [ ] **Step 3: Implement queued direction execution** + +In `rotateDial`, special-case `{kind:"model-presets"}`. Validate tick count and queue capacity, then enqueue one closure per detent containing only clockwise/counter-clockwise and registration identity. At closure start recheck the registration, obtain the latest target view, call the pure resolver, and only then construct `ModelPresetRequest`. + +- [ ] **Step 4: Add failing feedback, local patch, and race tests** + +Assert `SWITCHING…` renders before bridge resolution without displaying the target. On success assert local snapshot generation increments, both model and effort patch immutably, same registration redraws, and a held pre-command poll cannot overwrite. On partial/refused execution assert a forced refresh wins, actual pair renders `UNLISTED`, a temporary error restores current feedback safely, and no rollback command occurs. + +- [ ] **Step 5: Run lifecycle tests and capture RED** + +Expected: switching/patch/reconciliation assertions fail. + +- [ ] **Step 6: Implement local dispatch and registration-safe feedback** + +Add a dedicated `sendModelPresetToHost` path rather than overloading reasoning result parsing. Use explicit in-flight state serialized with `registration.rendering`; do not reuse `noticeActive` as the switching state. Parse the bridge result through exact descriptor-safe validation. Advance `localSnapshotGeneration` before the protected operation, patch only a valid exact confirmed result, and force authoritative reconciliation after failure. + +- [ ] **Step 7: Verify and commit Task 5** + +```bash +npx tsx --test test/dial-domain.test.ts test/controller-dial.test.ts test/micro-bridge.test.ts +npm run check +git diff --check +git add src/dial-types.ts src/dial-domain.ts src/controller.ts test/dial-domain.test.ts test/controller-dial.test.ts +git commit -m "feat: switch model presets from the dial" +``` + +### Task 6: Capability-negotiated relay support + +**Files:** +- Modify: `src/relay-protocol.ts` +- Modify: `src/codex-relay-server.ts` +- Modify: `src/codex-relay-client.ts` +- Modify: `src/controller.ts` +- Test: `test/relay.test.ts` +- Test: `test/controller-dial.test.ts` + +- [ ] **Step 1: Add failing strict protocol and compatibility tests** + +Define a capability `model-presets` and exact wire shapes: + +```ts +{ + kind: "model-preset", + modelId: "gpt-5.6-sol", + reasoningEffort: "high", + includeUltra: false, + includeModelPresetFeedback: true +} +``` + +and a successful result carrying both confirmed identifiers. Test extra/missing/accessor/symbol/unsafe/oversized fields. Test new-to-old refusal before `socket.send`, old-to-new unchanged behavior, and new-to-new acceptance. + +- [ ] **Step 2: Run relay tests and capture RED** + +```bash +npx tsx --test --test-name-pattern "model preset|model-presets" test/relay.test.ts test/controller-dial.test.ts +``` + +Expected: missing protocol command, capability, and result type. + +- [ ] **Step 3: Implement protocol, server, and client parsing** + +Extend the v1 additive capability list. Require capability only for the new command. Generalize pending command metadata and result unions without treating a model-preset result as a reasoning outcome. The server invokes `applyModelPreset`, validates exact output, forces a fresh snapshot, publishes it, then sends the result. + +- [ ] **Step 4: Add failing FIFO and identity-fence tests** + +Hold a pre-command snapshot, issue the model preset, and assert the server waits it out, forces/publishes a newer snapshot, then acknowledges. Test result rejection after socket generation, host ID, platform, ready identity, or snapshot generation changes. Assert the client patch is immutable and only affects the exact current host snapshot. + +- [ ] **Step 5: Run ordering tests and capture RED** + +Expected: old reasoning-only ordering and pending correlation are insufficient. + +- [ ] **Step 6: Implement remote dispatch and confirmed redraw** + +Wire `applyModelPreset` into the relay control created by `DeckController.start`. Refuse unsupported peers before send. Accept results only for the exact current generation and host identity after the forced snapshot barrier, patch both identifiers, and redraw only the current dial registration. On any mismatch reject and retain the last authoritative state. + +- [ ] **Step 7: Verify and commit Task 6** + +```bash +npx tsx --test test/relay.test.ts test/controller-dial.test.ts +npm run check +git diff --check +git add src/relay-protocol.ts src/codex-relay-server.ts src/codex-relay-client.ts src/controller.ts test/relay.test.ts test/controller-dial.test.ts +git commit -m "feat: relay confirmed model preset changes" +``` + +### Task 7: Documentation, independent review, release gates, merge, and install + +**Files:** +- Modify: `docs/STREAM_DECK_PLUS.md` +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `test/dial-action.test.ts` + +- [ ] **Step 1: Add a failing documentation-contract test** + +Assert the guide documents immediate wrapped application, per-knob live dropdowns, the three preferred defaults, truthful switching/confirmed/unlisted feedback, Ultra policy, unavailable-entry preservation/skipping, and no keyboard/focus navigation. + +- [ ] **Step 2: Run the guide test and capture RED** + +```bash +npx tsx --test --test-name-pattern "Stream Deck Plus guide" test/dial-action.test.ts +``` + +Expected: new exact claims are absent. + +- [ ] **Step 3: Update user-facing documentation** + +Add Model Presets to the recommended layout, configuration table, behavior section, troubleshooting, compatibility boundary, and physical QA checklist. Add concise README and changelog entries without claiming Windows physical verification. + +- [ ] **Step 4: Run focused docs tests and commit** + +```bash +npx tsx --test test/dial-action.test.ts test/release-audit.test.ts +git diff --check +git add docs/STREAM_DECK_PLUS.md README.md CHANGELOG.md test/dial-action.test.ts +git commit -m "docs: explain the model presets dial" +``` + +- [ ] **Step 5: Run independent final subagent reviews** + +Dispatch separate read-only spec-conformance, code-quality/security, and release-QA reviewers over `main...HEAD`. Correct every Critical or Important finding through a failing regression, minimal fix, focused verification, and a separate commit. Repeat review until approved. + +- [ ] **Step 6: Run all release gates serially** + +```bash +npm run check +npm test +npm run build +npm run validate +npm run audit:release +git diff --check main...HEAD +git status --short +``` + +Expected: typecheck passes; every test passes except the existing intentional platform skip; Stream Deck validation succeeds; all three release roots pass audit; range diff is clean; only validator-regenerated tracked plugin PNGs may be dirty. Restore only those two known generated PNGs before proceeding. + +- [ ] **Step 7: Run safe live and physical QA** + +First run a read-only production-expression probe that confirms the current catalog, unique active component, direct paired callback resolver, and current pair without invoking the callback. Back up the installed plugin, watcher runtime, LaunchAgent, and active Stream Deck + profile. Install the candidate, preserve a healthy Codex process, then physically verify the three preferred pairs, both wrap directions, rapid detents, Fast touch, restart persistence, and unavailable-entry handling. Record exact Codex, Stream Deck, macOS, and plugin versions. + +- [ ] **Step 8: Merge and verify local main** + +With a clean feature branch and approved reviews: + +```bash +git switch main +git merge --ff-only feat/model-presets-dial +npm run check +npm test +``` + +Expected: fast-forward succeeds and post-merge verification is green. Retain `feat/model-presets-dial` for the future pull request and do not push remotely. + +- [ ] **Step 9: Install the merged build and verify live readiness** + +Build from merged `main`, replace only the backed-up `com.simeo.codex-deck.sdPlugin`, reinstall the macOS watcher runtime, relaunch Stream Deck, and verify matching hashes, a running LaunchAgent, the existing Codex PID/bridge when healthy, `local=ready`, and `Codex Micro layout synchronized`. Confirm the Reasoning knob and all existing keypad actions remain unchanged. diff --git a/docs/superpowers/plans/2026-08-16-nondestructive-macos-bridge-recovery.md b/docs/superpowers/plans/2026-08-16-nondestructive-macos-bridge-recovery.md new file mode 100644 index 0000000..312cdea --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-nondestructive-macos-bridge-recovery.md @@ -0,0 +1,53 @@ +# Non-Destructive macOS Bridge Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore Stream Deck Micro readiness on Codex 26.810.52044 while making background Codex restarts impossible. + +**Architecture:** Simplify the watcher policy to degraded/retry-only behavior and delete its automatic recovery execution branch. Make the renderer runtime override use Statsig opportunistically while authorizing readiness from the native Micro event bus and verified input handlers. + +**Tech Stack:** TypeScript, Node.js test runner, CDP/WebSocket runtime evaluation, macOS LaunchAgent installer. + +--- + +### Task 1: Make Watcher Recovery Non-Destructive + +**Files:** +- Modify: `launcher/macos/watcher-policy.ts` +- Modify: `launcher/macos/codex-deck-macos.ts` +- Modify: `test/macos-watcher.test.ts` + +- [ ] Add failing policy tests asserting that prior bridge loss, process-generation replacement, a stopped interval, and prolonged unbridged state all return `wait` and never `restart-for-recovery`. +- [ ] Run `npx tsx --test test/macos-watcher.test.ts` and confirm the new tests fail on restart actions. +- [ ] Remove `restart-for-recovery` from `WatcherAction`; return a stable `wait` reason for unavailable bridges and retain health/state tracking. +- [ ] Delete the watcher-loop branch that calls `terminateCodex` and `launchCodex`; retain those helpers only for explicit user commands. +- [ ] Update the launcher self-test expectations to require degraded waiting. +- [ ] Run the focused watcher tests and `npm run check`. +- [ ] Commit the watcher slice as `fix: prevent automatic Codex restarts`. + +### Task 2: Support Current Native Micro Activation + +**Files:** +- Modify: `launcher/runtime-override.ts` +- Modify: `test/launcher.test.ts` + +- [ ] Add a failing executable-expression test with no `globalThis.__STATSIG__`, a native event bus, device handlers, and a successful connected-device dispatch. +- [ ] Add compatibility tests for an available Statsig client and for absent native handlers. +- [ ] Run `npx tsx --test test/launcher.test.ts` and confirm no-Statsig activation fails with `statsig-client-unavailable`. +- [ ] Remove the early Statsig failure; apply gate overrides only to discovered clients, then continue native event-bus discovery and dispatch. +- [ ] Define readiness so a verified native path succeeds without Statsig while missing bus/handlers remains unavailable. +- [ ] Run focused launcher and watcher tests plus `npm run check`. +- [ ] Commit the runtime slice as `fix: activate Micro without legacy Statsig`. + +### Task 3: Full Verification and Installation + +**Files:** +- Generated by existing build/install commands only; do not hand-edit installed artifacts. + +- [ ] Run `npm test`, `npm run build`, `npm run validate`, `npm run audit:release`, and `git diff --check`. +- [ ] Back up the installed plugin and current `CodexDeck` runtime state. +- [ ] Install the built plugin/runtime while the destructive watcher remains disabled. +- [ ] Invoke one explicit controlled launcher restart to start Codex with a loopback CDP port. +- [ ] Verify the bridge state file, listener, plugin connection, `local=ready`, synchronized layout, and functioning controls. +- [ ] Re-enable the LaunchAgent, simulate/read a degraded observation without terminating Codex, and confirm the Codex PID remains stable. +- [ ] Commit any test-only correction separately; push the branch and update the open pull request when all gates remain green. diff --git a/docs/superpowers/specs/2026-08-09-fast-mode-reasoning-controls-fix-design.md b/docs/superpowers/specs/2026-08-09-fast-mode-reasoning-controls-fix-design.md new file mode 100644 index 0000000..47ac6f0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-fast-mode-reasoning-controls-fix-design.md @@ -0,0 +1,111 @@ +# Fast Mode Feedback and Reasoning Dial Correction + +**Date:** 2026-08-09 +**Status:** Approved design; implementation pending + +## Problem + +Two behaviors in the installed Stream Deck + integration do not match the live Codex composer: + +1. Pressing the Fast Mode key changes Codex state but leaves the Stream Deck key on its static background, so the hardware does not show whether Fast Mode is enabled. +2. Rotating the Reasoning dial sends raw Codex Micro encoder events. In the current Codex desktop app those events move composer focus between controls, producing the blue focus ring shown in the reported screenshot instead of changing reasoning effort. + +## Confirmed Current Codex Contracts + +Live inspection of Codex desktop `26.803.41515` confirmed: + +- the visible composer reasoning trigger is marked with both `data-codex-intelligence-trigger="true"` and `data-composer-navigation-target="reasoning"`; +- the current reasoning effort is reported by `data-selected-reasoning-effort`; +- Fast Mode is represented by the inline Fast indicator inside that visible trigger and by the renderer state `selectedServiceTier: "priority"` / `selectedServiceTierIconKind: "fast"`; +- the official `FAST` keycap invokes `composer.toggleFastMode`; +- the official `MIND+` and `MIND-` keycaps invoke `composer.increaseReasoningEffort` and `composer.decreaseReasoningEffort` respectively. + +The bridge already has a guarded official-keycap command path. The reasoning correction will use that path instead of raw encoder navigation. + +## User-Visible Behavior + +### Fast Mode + +- Every rendered Stream Deck key whose effective Codex keycap is `FAST` uses a state background: + - green when Fast Mode is confirmed enabled; + - red when Fast Mode is confirmed disabled; + - the existing neutral theme when the live state cannot be verified. +- This applies to the existing page's Fast key and any separately configured official Fast key. +- The keycap glyph remains recognizable above the colored background. +- After a successful local Fast command, the controller requests a fresh snapshot rather than waiting for the normal polling interval. Remote commands use the relay's existing post-command snapshot publication. +- Failed commands do not optimistically flip the displayed state. + +### Reasoning Dial + +- Each clockwise detent invokes the official increase-reasoning command once. +- Each counter-clockwise detent invokes the official decrease-reasoning command once. +- Rotation does not send `ENC_CW` or `ENC_CC`, move composer focus, open the reasoning menu, or synthesize keyboard navigation. +- The existing reasoning feedback continues to display the effort reported by the active visible composer. +- The legacy encoder-click action remains unchanged; only reasoning adjustment uses the dedicated commands. + +## Architecture and Data Flow + +### Snapshot state + +`MicroSnapshot` gains an optional `fastModeEnabled` boolean. + +The renderer bridge reads the active visible reasoning trigger. When that verified trigger exists, presence of its inline Fast indicator yields `true` and absence yields `false`. If no verified visible trigger exists, the field is omitted rather than treating unknown as disabled. + +Relay normalization accepts only an optional literal boolean. Malformed values are rejected at the existing snapshot trust boundary. + +### Rendering + +The keycap renderer gains an optional toggle-state treatment. Existing callers remain neutral by default. The controller supplies the Fast state only when the effective keycap ID is `FAST`. + +The green and red backgrounds reuse the project's existing ready/error signal palette so they stay consistent with other Stream Deck status surfaces. Light and dark foreground contrast remain readable. + +### Command refresh + +After a successful local Fast press or official Fast keycap command, the controller refreshes the local snapshot and redraws registered actions. It does not refresh on the corresponding release event. Remote execution remains authoritative through the relay server's existing command barrier and freshly published snapshot. + +### Reasoning commands + +`CodexMicroRendererBridge.adjustReasoning(direction)` maps: + +- `increase` to official keycap `MIND+`; +- `decrease` to official keycap `MIND-`. + +It then uses the existing allow-listed keycap command runner. Controller and relay command types remain unchanged, so local and remote dials share the same corrected behavior without a protocol migration. + +## Error Handling + +- Missing or ambiguous composer Fast state renders neutral, never red. +- A Fast command failure keeps the last authoritative state and preserves the existing Stream Deck alert/log behavior. +- A failed forced refresh preserves the last known snapshot and reports degraded health through the existing bridge path. +- If the official reasoning keycap command is unavailable, the action fails visibly; it does not fall back to raw encoder navigation. + +## Testing + +Implementation will proceed test-first with regressions for: + +1. visible active-composer Fast state reads as true and false, while hidden or absent controls remain unknown; +2. optional Fast state survives snapshot and relay validation, and malformed non-booleans fail closed; +3. Fast key rendering is green when enabled, red when disabled, and neutral when unknown; +4. both Micro-slot Fast and official-keycap Fast rendering use the same state; +5. successful local Fast activation forces a fresh snapshot, while release and failed commands do not fabricate a state change; +6. reasoning increase/decrease invoke `MIND+` / `MIND-` exactly once per detent and never emit raw encoder rotation events; +7. the full type-check, test, build, Stream Deck validation, and release audit remain green. + +## Installation and Acceptance + +After review and verification, the corrected bundle will replace the installed development plug-in using the existing recoverable backup process. Stream Deck will be relaunched and checked for: + +- green/red Fast feedback matching the visible Codex composer; +- persisted Reasoning, Agents, Actions, and Usage dial settings; +- live bridge health; +- no changes to the eight existing keypad assignments or the other three dial presets. + +The final tactile acceptance check is one Fast press and one reasoning detent in each direction on the physical Stream Deck +. + +## Out of Scope + +- Redesigning other keycap backgrounds. +- Optimistic toggle state. +- DOM-clicking the reasoning dropdown. +- Changing the legacy encoder-click action. +- Publishing or opening an upstream pull request. diff --git a/docs/superpowers/specs/2026-08-09-stream-deck-plus-codex-dial-design.md b/docs/superpowers/specs/2026-08-09-stream-deck-plus-codex-dial-design.md new file mode 100644 index 0000000..24736b8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-stream-deck-plus-codex-dial-design.md @@ -0,0 +1,403 @@ +# Stream Deck + Codex Dial Design + +**Date:** 2026-08-09 + +**Status:** Approved by user + +**Repository:** `dazer1234/codex-stream-deck` + +**Target branch:** `feature/stream-deck-plus-dials` + +## Summary + +Add a single Encoder-only Stream Deck action named **Codex Dial**. Each action instance is configured independently for one Stream Deck + knob and its associated touch-strip region. A preset initializes sensible behavior, while rotation, dial press, touch tap, and feedback display remain separately configurable. + +The recommended four-knob profile is the approved **Status-focused** preset: + +| Knob | Rotation | Dial press | Touch tap | Default feedback | +|---|---|---|---|---| +| Reasoning | Decrease/increase reasoning one level per detent | No action | Fast mode | Current reasoning effort | +| Agents | Highlight previous/next occupied agent | Focus highlighted task | Tasks view | Agent title, state, and context use | +| Actions | Highlight previous/next configured action | Run highlighted action | Codex Micro setup | Selected action and activation hint | +| Usage | Cycle Automatic/5-hour/Weekly | Toggle single-window/overview | Refresh usage | Remaining capacity and reset time | + +The feature is implemented through the existing typed controller, local Codex renderer bridge, and optional authenticated multi-host relay. It adds no shell-command surface, hotkey fallback, USB driver, arbitrary CDP endpoint, or second protocol. + +## Goals + +- Make Stream Deck + knobs and touch regions first-class Codex controls. +- Allow each knob to be configured independently. +- Support both immediate paired rotation controls and rotate-to-highlight selectors. +- Keep dial press and touch tap independently assignable. +- Provide useful live feedback on the touch strip. +- Preserve existing keypad actions, saved profiles, host routing, and usage-source semantics. +- Produce a focused, documented, tested change suitable for an upstream pull request. + +## Non-goals + +- Replacing or removing existing keypad actions. +- Exposing arbitrary commands, URLs, scripts, renderer evaluation, filesystem access, or shell execution through dial settings. +- Emulating a USB HID device or installing a driver. +- Changing launcher or watcher lifecycle policy. +- Bundling personal Stream Deck profiles or machine-specific configuration in the repository. +- Changing plugin version or creating an upstream release unless requested by the maintainer. +- Claiming Windows physical-device coverage that was not performed. + +## User model + +The manifest exposes one Encoder-only action with a stable UUID: + +```text +com.simeo.codex-deck.codex-dial +``` + +Users drag **Codex Dial** to any Stream Deck + dial. Each instance stores a versioned settings object. The property inspector offers these presets: + +- Reasoning +- Agents +- Actions +- Navigation +- Usage +- Custom + +Choosing a preset writes a complete set of defaults. Editing any field preserves the preset as provenance but labels the instance as customized; it never changes another dial. + +### Settings model + +The persisted settings are JSON-only and validated against allow-listed identifiers. A representative shape is: + +```ts +type CodexDialSettingsV1 = { + version: 1; + preset: "reasoning" | "agents" | "actions" | "navigation" | "usage" | "custom"; + customized: boolean; + rotation: PairedRotation | SelectorRotation; + press: DialBinding | null; + touchTap: DialBinding | null; + feedback: DialFeedbackMode; +}; +``` + +`PairedRotation` holds independent counter-clockwise and clockwise typed bindings. `SelectorRotation` holds an allow-listed selector source, wrapping preference, and—in the configured-actions case—an ordered list of allowed action identifiers. Unknown versions or malformed fields normalize to safe preset defaults; they never become executable strings. + +## Interaction semantics + +### Paired rotation + +- Positive ticks invoke the clockwise binding; negative ticks invoke the counter-clockwise binding. +- Every physical detent produces one ordered command. +- Rapid turns are serialized so commands do not overlap or reorder. +- No confirmation is shown for reasoning or other obvious adjustments. +- Reaching a supported range boundary is a harmless no-op. +- An unavailable command produces the standard Stream Deck alert and an honest unavailable feedback state. + +### Selector rotation + +- Rotation changes only the highlighted item; it does not execute the item. +- Dial press activates the highlighted item. +- Lists wrap by default; wrapping can be disabled. +- Agent selection skips empty slots and preserves the highlighted task by stable identity when snapshots reorder. +- Configured action selection uses the user-defined order and allow-listed action IDs. +- Usage selection cycles `auto`, `five-hour`, and `weekly`. + +### Dial press and release + +Dial actions receive both down and up events. The dispatcher preserves the existing lifecycle of the selected command: + +- Momentary Micro/agent bindings send their existing down/up pair. +- One-shot keycap and navigation bindings run once. +- A null binding performs no action. +- Built-in safeguards stay with their command. In particular, a rate-limit reset cannot bypass the existing deliberate hold and applicability checks. + +### Touch tap + +Touch tap is a separate one-shot binding. It may be unassigned. Touch coordinates are not used to create hidden sub-actions within a dial segment in version 1. + +### Rotation while pressed + +Version 1 treats rotation the same whether or not the dial is held down. The SDK's `pressed` flag is retained as a future extension point but does not introduce a second hidden binding layer. + +## Binding and selector catalog + +The dial layer uses typed identifiers that map to existing controller operations: + +- Reasoning increase/decrease +- Agent focus +- Codex Micro actions (`ACT06` through `ACT12`) +- Joystick navigation (plan, forward, sidebar, back) +- New task +- Host toggle +- Existing official keycap commands +- Usage refresh and display-mode changes +- Usage overview toggle +- Rate-limit reset only as a dial-press binding through its existing safeguarded hold lifecycle +- No action + +The property inspector groups these choices and hides context-incompatible entries, but the runtime parser remains authoritative. In particular, rate-limit reset is not valid for rotation or touch tap because those gestures cannot preserve its deliberate hold lifecycle. + +Selector sources in version 1 are: + +- Occupied agents from the current merged host snapshot +- A user-ordered list of allowed Codex actions +- Usage windows (`auto`, `five-hour`, `weekly`) + +Navigation is a paired-control preset rather than a selector source. + +## Status-focused defaults + +### Reasoning + +- Rotation mode: paired +- Counter-clockwise: reasoning decrease +- Clockwise: reasoning increase +- Press: none +- Touch tap: Fast mode +- Feedback: current reasoning effort + +Reasoning changes apply immediately per detent. Press is not a confirmation step. + +### Agents + +- Rotation mode: selector over occupied agents +- Wrap: enabled +- Press: focus highlighted agent +- Touch tap: Tasks view +- Feedback: agent title, status, host badge when relevant, and context percentage when available + +### Actions + +- Rotation mode: configured-action selector +- Default order: Fast, Approve, Reject, Fork, Dictation, Send +- Wrap: enabled +- Press: run highlighted action +- Touch tap: Codex Micro setup/settings action +- Feedback: selected action plus `PRESS TO RUN` + +### Usage + +- Rotation mode: usage selector +- Default order: Automatic, 5-hour, Weekly +- Wrap: enabled +- Press: toggle selected single-window view and two-window overview +- Touch tap: request an immediate usage refresh +- Feedback: selected mode, remaining percentage, reset countdown, health, and single/overview state + +## Feedback design + +The manifest references a custom 200 x 100 Encoder layout. The layout contains stable keyed fields rather than receiving arbitrary SVG markup for every refresh: + +- title +- primary value +- secondary value +- optional progress indicator +- status/accent indicator +- optional activation hint + +The action calls `setFeedback` only when the normalized feedback payload changes. It uses the existing controller refresh loop and image-write caching principles to avoid unnecessary USB traffic. + +Feedback modes include: + +- Auto/follow rotation +- Current reasoning effort +- Selected agent +- Selected action +- Navigation pair +- Usage window/overview +- Static label + +Unknown, stale, degraded, disconnected, and unavailable values are labeled explicitly. Last-known information may remain visible as display-only state, consistent with existing host health behavior, but command dispatch still requires a healthy applicable route. + +## Current reasoning feedback + +The existing snapshot does not expose the current composer reasoning effort. The renderer bridge will make a best-effort, read-only discovery of the current effort from Codex's live renderer-owned state and add it as an optional normalized snapshot field. + +```ts +type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {}); + +type MicroSnapshot = { + // existing fields... + reasoningEffort?: ReasoningEffort; +}; +``` + +The implementation must not guess the value from the last dial movement because the effort can also change in Codex or on another paired control. If the current Codex build does not expose a confidently resolved value, the field is absent and feedback reads `REASONING · UNAVAILABLE`. + +The field is optional in local and relay schemas, preserving compatibility with older paired plugin versions. Relay validation accepts its absence and rejects non-string or unreasonably large values. + +## Component changes + +### Manifest and static assets + +- Add one `Controllers: ["Encoder"]` action. +- Add Encoder icon/background assets as needed. +- Add a custom layout JSON and trigger descriptions for rotate, push, and touch. +- Add one property-inspector HTML file for presets and advanced binding controls. +- Keep SDK version 2 and Stream Deck minimum 6.6 unless validation proves a higher minimum is technically required. + +### Dial domain module + +A pure module owns: + +- Settings types and versioning +- Preset expansion +- Settings normalization +- Binding and selector allow lists +- Tick-to-command expansion +- Selector wrapping/clamping +- Stable selection reconciliation +- Feedback-model derivation + +These functions do not depend on Stream Deck events and are unit tested directly. + +### Stream Deck action + +`CodexDialAction` owns: + +- `onWillAppear`/`onWillDisappear` registration +- `onDidReceiveSettings` normalization and refresh +- `onDialRotate` +- `onDialDown`/`onDialUp` +- `onTouchTap` +- Per-instance selector state +- A serialized per-instance command queue +- Feedback updates and standard alert handling + +It must assert `ev.action.isDial()` before dial-only feedback calls. + +### Controller + +The controller gains a dial registration map and narrow methods that: + +- Resolve typed bindings into existing controller calls +- Select the correct host using current rules +- Register/unregister visible dial instances +- Re-render registered dials during normal refreshes +- Request a usage refresh without exposing a raw API surface + +Existing keypad registrations and methods remain intact. + +### Renderer bridge and relay + +- Extend the local snapshot with optional reasoning effort. +- Extend relay normalization/validation for the optional field. +- Preserve current routing: + - Agent focus routes to the agent's owning host. + - Function, navigation, reasoning, and keycap controls route to the selected host. + - Usage remains account-scoped and prefers a healthy local snapshot. +- Do not broaden the relay command union beyond typed operations needed by existing controls and the new usage-refresh behavior. + +## Property inspector behavior + +- Preset is the first field. +- Rotation mode selects paired controls or selector. +- Paired mode shows counter-clockwise and clockwise binding fields. +- Selector mode shows source, wrap toggle, and—when applicable—an ordered action list. +- Press, touch tap, and feedback remain visible as independent fields. +- Changing the preset replaces all fields after a clear user selection. +- Changing an individual field sets `customized: true`. +- Settings are persisted with the standard Stream Deck property-inspector API. +- Trigger descriptions should reflect the current instance where supported; static manifest descriptions remain accurate fallbacks. + +## Error handling + +- Malformed settings normalize to a known preset; they do not crash the plugin. +- Unknown action IDs are removed from selectors and never dispatched. +- Empty selectors render `NO ITEMS` and pressing shows a standard alert. +- Lost agent identity reconciles to the nearest occupied item or no selection. +- Command failures stop the current gesture lifecycle, log a concise error, and show the standard alert. +- A queued rotation command is not retried automatically after a bridge failure. +- Feedback failures are logged and deduplicated; they do not stop controller refreshes. +- Snapshot fields that cannot be discovered are absent rather than fabricated. + +## Compatibility and migration + +- Existing action UUIDs and keypad profiles are unchanged. +- New per-dial settings start at version 1. +- Missing settings load the Reasoning preset as the safe single-action default when a user first drops Codex Dial. +- Optional relay fields allow mixed versions to continue operating. +- The same plugin code and manifest continue to support Windows 10+ and macOS 13+. +- No launcher, watcher, pairing, or trust-boundary change is required. +- Internal Codex renderer compatibility remains explicitly best effort and documented. + +## Testing strategy + +### Unit tests + +- Every preset expands to the expected settings. +- Malformed and unknown settings normalize safely. +- Positive, negative, zero, and multi-tick rotation expand correctly. +- Per-detent commands are serialized in order. +- Selectors wrap, clamp, and handle empty lists. +- Agent selection skips empty slots and reconciles by stable identity. +- Configured action order is preserved and unknown IDs are removed. +- Press/down/up and touch one-shot lifecycles dispatch correctly. +- Usage mode and overview state transition correctly. +- Feedback models cover ready, unavailable, degraded, stale, and offline states. +- Optional reasoning effort survives local and relay normalization. +- Older snapshots without reasoning effort remain valid. + +### Repository validation + +Run the repository's required checks: + +```text +npm ci +npm run check +npm test +npm run validate +npm run audit:release +``` + +### Physical macOS verification + +On the user's Stream Deck +: + +- Back up the installed plugin and affected profile before replacement. +- Install the locally built plugin. +- Place four Codex Dial instances on the Status-focused page. +- Verify each knob's counter-clockwise/clockwise rotation. +- Verify dial down/up behavior and unassigned press behavior. +- Verify each touch region independently. +- Verify selector preview does not execute until press. +- Verify fast turns preserve tick count and order. +- Verify touch feedback updates after Codex-side state changes. +- Verify unavailable/disconnected rendering and reconnection. +- Verify the existing eight keypad actions still work. +- Verify settings persist across Stream Deck and Codex restarts. + +The pull request will state the exact Codex, Stream Deck, macOS, and hardware versions used. Windows CI/build validation will be reported separately from physical-device coverage. + +## Delivery and Git strategy + +Work proceeds on `feature/stream-deck-plus-dials` from current upstream `main`. Commits are made between reviewable slices: + +1. Dial domain core and tests +2. Encoder integration, property inspector, layout, and tests +3. Live reasoning feedback and relay compatibility tests +4. Documentation and verification notes + +Subagents may implement isolated slices after the implementation plan assigns non-overlapping ownership. Separate subagents review functional correctness and run QA against the acceptance criteria. Review fixes receive their own focused commit or amend only the unshared current slice. + +After automated and physical verification: + +- Perform a final diff and repository-state review. +- Merge the feature branch into local `main` using a normal non-destructive merge. +- Re-run the relevant verification from `main`. +- Fork the upstream repository under the user's GitHub account if needed. +- Push the feature branch and prepare an upstream pull request with exact evidence. + +Personal profile manifests, local paths, logs, bridge metadata, credentials, generated release bundles, and brainstorming artifacts are never committed. + +## Acceptance criteria + +- A user can place one Codex Dial action independently on each Stream Deck + knob. +- Each instance can choose a preset and independently configure rotation, press, touch tap, and feedback. +- Paired rotation executes exactly once per detent without confirmation. +- Selector rotation previews without execution; press activates the highlighted item. +- Status-focused defaults match the approved four-knob table. +- Touch feedback shows live, honest state and never invents a reasoning value. +- Existing keypad actions and profiles continue to work. +- Windows/macOS and optional relay routing rules remain intact. +- All required repository checks pass. +- Physical Stream Deck + behavior is verified on macOS and documented accurately. +- Repository history is clean, reviewable, and ready for an upstream pull request. diff --git a/docs/superpowers/specs/2026-08-10-reasoning-dial-immediate-feedback-design.md b/docs/superpowers/specs/2026-08-10-reasoning-dial-immediate-feedback-design.md new file mode 100644 index 0000000..221e185 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-reasoning-dial-immediate-feedback-design.md @@ -0,0 +1,124 @@ +# Reasoning Dial Immediate Feedback Design + +## Problem + +Reasoning detents send a Codex command but do not update the Stream Deck feedback afterward. The displayed level changes only when the controller's global 1.2-second snapshot poll runs, so a successful detent takes roughly 0–1.2 seconds plus rendering time to appear. Rapid detents queue behind that work and can feel slower. Action-selector rotation is local state and renders immediately, which explains the contrast. + +The installed runtime log also shows repeated `Codex reasoning metadata is unavailable` failures. The earlier React-tree interpretation was disproved by a later read-only live probe: beneath the unique visible reasoning trigger, ordinary DOM contains a hidden measurement label `5.3 Codex Spark` and multiple visible leaf texts, including current effort `high` and model label `5.6 Sol`. The validated `models/list` query cache contains the one catalog record matched by those candidates: `displayName` `GPT-5.6-Sol` and model ID `gpt-5.6-sol`. That record, not the DOM or a React sibling/`selectedValue` value, is authoritative for the ordered supported reasoning efforts. The current extractor therefore fails before dispatch because it authorizes the model from the wrong source even though all required data exists. + +This design is a forward correction that supersedes the model-discovery behavior introduced by commits `c9067e5`, `3f8ca81`, and `3c6c7f2`. Those commits remain in history; no history rewrite is required. + +## Goals + +- Make a successful Reasoning detent update the Stream Deck as soon as Codex confirms the resulting level, without waiting for the 1.2-second background poll. +- Keep feedback authoritative. Never invent or optimistically advance a level Codex has not reported. +- Prevent pre-command local or remote snapshot work from regressing a confirmed feedback patch. +- Preserve the per-knob `Include Ultra` policy and the exact temporary `ULTRA OFF` notice. +- Discover bounded visible, non-hidden, non-measurement ordinary-DOM leaf text candidates beneath the unique visible reasoning trigger, then accept only when their normalized values identify exactly one validated `models/list` record by `displayName`. +- Preserve protocol-v1 rolling upgrades by negotiating confirmed effort feedback separately from the existing reasoning policy. +- Preserve local and current-generation relay behavior; legacy unrestricted peers may continue without immediate confirmed-level feedback. + +## Non-goals + +- Do not lower the global poll interval. +- Do not add a new control or change the approved dial UI. +- Do not change the Action selector, keypad Reasoning controls, Fast Mode, or native Codex confirmation dialogs. +- Do not add optimistic animation or synthetic intermediate levels. +- Do not use React `selectedValue`, React siblings, or any other React model value to authorize the active model. +- Do not accept a candidate set that maps to zero or more than one distinct normalized catalog record, or hidden, measurement, accessor-backed, proxy-backed, malformed, over-bound, or traversal-truncated model/catalog data. Multiple visible leaves are expected; unmatched leaves such as the current effort are ignored. + +## Considered Approaches + +### 1. Confirmed result with immediate render — selected + +The renderer returns both the command outcome and the confirmed current effort. The controller commits that exact value to the matching host snapshot and redraws dial feedback immediately. The background poll remains a reconciliation mechanism. + +This is the only approach that is both snappy and honest. It reuses the confirmation already performed by the Ultra guard and adds the same bounded confirmation to unrestricted/decrease commands. + +### 2. Immediate full snapshot refresh + +Run `microBridge.refresh()` after every detent. This is authoritative but refreshes every Micro signal, adds avoidable React traversal and rendering work, and serializes that cost into rapid detents. + +### 3. Optimistic local advancement + +Advance the displayed level before Codex confirms it, then reconcile later. This feels instant but can show a level Codex rejected or did not support, contrary to the status-focused design. + +## Architecture + +### Confirmed execution result + +Keep `ReasoningAdjustmentResult` as the outcome vocabulary (`applied` or `blocked-ultra`) and add a structured execution result: + +```ts +type ReasoningAdjustmentExecution = { + outcome: ReasoningAdjustmentResult; + reasoningEffort?: string; +}; +``` + +`reasoningEffort` is present only when the renderer authoritatively reads a bounded safe effort after the command, or when it blocks before Ultra with an authoritative current effort. An applied result without a confirmed effort remains valid for a legacy unrestricted relay response but does not trigger an immediate local display change. + +### Renderer behavior + +- Model-label discovery starts at the one visible semantic reasoning trigger and inspects only bounded ordinary-DOM leaf text descendants. It excludes hidden and measurement nodes and collects every visible, non-empty, bounded leaf candidate; multiple candidates are normal because the trigger also renders the current effort. +- Each DOM candidate is strictly normalized and compared only with the strictly normalized `displayName` of fully validated `models/list` query-cache records. Normalization is an exact token transform: accept bounded safe strings, trim and ASCII-case-fold them, tokenize only on spaces/hyphens, remove only the exact leading `gpt` token from catalog display names, and compare the entire remaining token sequence. It is not substring or fuzzy matching. In the live shape, `high` is unmatched and ignored while `5.6 Sol` identifies the `GPT-5.6-Sol` record and its model ID `gpt-5.6-sol`; React `selectedValue` and sibling model data have no authorization role. +- Catalog extraction remains descriptor-safe and bounded. Accept only when the complete candidate-to-catalog comparison produces exactly one distinct matching validated record. Zero matches or more than one distinct matching record, malformed/accessor/proxy-backed data, bounds exhaustion, or traversal ambiguity fail closed; repeated/unmatched DOM leaves do not by themselves create ambiguity. +- Restricted increases read the ordered `supportedReasoningEfforts` from that same uniquely matched, fully validated record before dispatch. If the next effort is Ultra, they return `blocked-ultra` without running a command. +- Applied commands poll the visible reasoning trigger for a bounded confirmation and return the confirmed effort when observed. +- If an interior adjustment cannot be confirmed, the bridge fails honestly rather than fabricating the next value. A boundary no-op may return the unchanged authoritative value only when the current supported order proves the requested direction is already at its boundary. +- The existing renderer-global serialization and uncertainty reservation remain in force. + +### Controller behavior + +After a local result with `reasoningEffort`, the controller synchronously increments `localSnapshotGeneration` before immutably replacing the matching local snapshot's reasoning field, with no `await` between those operations. Any refresh that captured the older generation is then unable to commit its pre-command snapshot. The controller immediately rerenders registered dials from the patched snapshot. + +For remote results, the relay server provides the stale-read barrier. For an opted-in reasoning command, it waits for any prior `snapshotInFlight`, forces and publishes a fresh post-command snapshot with `publishSnapshot(undefined, true)`, and only then sends the result on the same socket. The existing `currentSnapshotMessage(true)` path awaits the prior read before starting the fresh `control.refresh()`; the older publisher sends its snapshot before the forced publisher, and WebSocket FIFO preserves the older snapshot, fresh snapshot, result order. The relay client patches only the matching retained snapshot's reasoning field when the result belongs to the current ready connection generation and host; the controller then rerenders. No new per-command remote sequence counter is needed because a pre-command snapshot cannot arrive after the result under this server barrier; the existing connection/snapshot generation checks remain in force. + +The 1.2-second poll continues normally and replaces the field with the next authoritative snapshot. A command failure, missing confirmation, stale relay generation, or malformed result does not change displayed feedback. + +### Relay protocol + +Protocol v1 gains an additive `reasoning-feedback` capability. A client sends `includeReasoningFeedback: true` on a reasoning command only when the current ready peer advertises that capability. The server returns bounded `reasoningEffort` only for a successful reasoning command that carried that opt-in; no opt-in means no extra result key even when the server internally confirmed an effort. This does not alter `reasoning-policy`: restricted commands still require that existing capability, and legacy unrestricted peers without it may still map an outcome-less response to `applied` without manufacturing an effort. + +Rolling upgrades remain symmetric: + +- Old client to new server: the client sends the legacy reasoning shape; the server omits `reasoningEffort` and returns the legacy-compatible result. +- New client to old server: because the peer does not advertise `reasoning-feedback`, the client sends the legacy shape and accepts the existing outcome, or the existing outcome-less unrestricted success when `reasoning-policy` is also absent. +- New client to new server: the client opts in, the server completes the post-command snapshot barrier before its result, and the result carries the exact confirmed effort. + +Command and result parsing remains exact-data-only. It rejects false/unknown feedback flags, accessors, symbols, extra keys, malformed efforts, an effort without a successful opted-in reasoning request, and opted-in reasoning success without an outcome or confirmed effort. Pending requests record command kind, feedback opt-in, ready host, and connection generation so a late or mismatched result cannot patch state. + +## Timing and UX Contract + +- The dial redraw is causally attached to the successful command result; it must not depend on the scheduled 1.2-second poll. +- Tests will hold the scheduled refresh indefinitely and prove the dial still changes to the confirmed effort. +- A local refresh that began before the command cannot regress the confirmed patch after it is released. On relay, an older same-generation snapshot and the forced post-command snapshot both arrive before the opted-in result, so neither can regress the result patch afterward. +- Rapid detents remain serialized so every command is ordered, but each confirmed result can update feedback before the next queued detent completes. +- `ULTRA OFF` keeps its existing 1.2-second notice and lifecycle protections. + +## Error Handling + +- Metadata ambiguity or absence fails closed and reports the existing dial alert/error path. +- No confirmed effort means no immediate feedback mutation. +- The local generation advance rejects pre-command refresh commits. The relay post-command publication barrier and same-socket FIFO order older snapshot, fresh snapshot, then result; current ready-connection and host checks reject late results from stale generations. +- The command runner remains restricted to the two verified reasoning commands and cannot bypass protected keycap access controls. + +## Testing + +- Unit-test ordinary-DOM leaf discovery with the current live shape: hidden measurement `5.3 Codex Spark`; visible leaf candidates including effort `high` and model `5.6 Sol`; and exactly one matched validated catalog record whose `displayName` is `GPT-5.6-Sol` and model ID is `gpt-5.6-sol`. +- Prove unmatched visible effort/other leaf candidates are ignored and the matched record's ordered `supportedReasoningEfforts` is preserved without hardcoding an order not established by the live probe. Cover zero matches, more than one distinct matching catalog record, normalization collisions, React-only model data, hidden/measurement nodes, accessors, proxies, malformed data, and depth/node/query traversal bounds. +- Execute the serialized renderer expression and prove applied/blocked results carry only authoritative efforts. +- Controller tests must prove immediate feedback with the global poll disabled, no update on failure/unconfirmed results, ordered rapid detents, and local generation invalidation. Deterministically hold a refresh that captured the pre-command generation, apply and render a confirmed effort, release the old refresh, and prove it cannot regress the display. +- Relay tests must cover the additive `reasoning-feedback` capability, opt-in command/result exactness, both rolling-upgrade directions, preservation of `reasoning-policy`, malformed efforts, and current-ready-generation/host patching. A deterministic same-generation test must hold a pre-command server refresh, execute an opted-in reasoning command, release the old read, observe old snapshot then forced fresh snapshot then result, and prove the client display cannot regress after the result. +- Run TypeScript checks, the complete test suite, Stream Deck package validation, the three-root release audit, and a live read-only Codex metadata probe before installation. + +## Acceptance Criteria + +- A normal Reasoning detent updates the Stream Deck immediately after Codex confirms the new level and does not wait for the background poll. +- The current live Codex shape yields effort `high`, multiple visible leaf candidates including `high` and model label `5.6 Sol`, and model ID `gpt-5.6-sol`; unmatched effort text is ignored, exactly one validated `GPT-5.6-Sol` record matches, and its supported effort order is preserved rather than replaced by an unverified hardcoded order. +- Hidden measurement label `5.3 Codex Spark`, React sibling/`selectedValue` data, ambiguous normalization matches, and unsafe or truncated catalog data cannot authorize a model. +- Turning above the highest non-Ultra level with Ultra excluded still shows `ULTRA OFF` and runs no increase command. +- Action-selector behavior is unchanged. +- No unconfirmed, failed, stale-host, or malformed result changes the displayed reasoning level. +- A pre-command local refresh cannot overwrite a confirmed local patch, and a pre-command same-generation remote snapshot cannot arrive or commit after an opted-in result. +- Protocol v1 rolling upgrades preserve both old-client/new-server and new-client/old-server reasoning behavior; only mutually capable, explicitly opted-in peers exchange `reasoningEffort`. diff --git a/docs/superpowers/specs/2026-08-10-reasoning-ultra-toggle-design.md b/docs/superpowers/specs/2026-08-10-reasoning-ultra-toggle-design.md new file mode 100644 index 0000000..8047c15 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-reasoning-ultra-toggle-design.md @@ -0,0 +1,114 @@ +# Reasoning Dial Ultra Toggle Design + +**Date:** 2026-08-10 +**Status:** Approved + +## Problem + +Codex shows a safety dialog titled `Use Ultra with Full access?` when reasoning crosses into Ultra while Full access is active. The Reasoning dial currently invokes Codex's dedicated increase command for every clockwise detent, so reaching the top of the range can open that dialog even when the user does not want Ultra available from the hardware control. + +This dialog is owned by Codex and protects a meaningful permission boundary. Codex Deck must neither accept nor dismiss it automatically. + +## Goals + +- Add a per-dial `Include Ultra` checkbox. +- Default the checkbox to off for new Reasoning presets and settings that predate the field. +- When off, prevent that dial from crossing into Ultra while preserving all lower reasoning levels. +- When on, preserve current behavior, including Codex's own confirmation dialog. +- Leave manual reasoning selection inside Codex unchanged. +- Apply the same policy to local and authenticated relay-controlled Codex hosts. +- Keep standalone Reasoning Up keypad actions unchanged; this is a dial setting. + +## Non-goals + +- Do not change Codex's hidden global `enabled-reasoning-efforts` or `model_picker_persists_ultra_effort` settings. +- Do not automate, dismiss, or bypass the Full-access safety dialog. +- Do not change Full access or any other Codex permission mode. +- Do not infer that Ultra is active merely because the last dial command requested an increase. + +## Considered approaches + +### 1. Per-dial runtime guard — selected + +Persist an `includeUltraReasoning` boolean with each Codex Dial. Before an increase command runs, the target renderer determines the live current effort and the ordered efforts supported by the active model. If the next effort is Ultra and the setting is false, the bridge returns a blocked result without issuing the command. + +This is isolated to the hardware control, works with independent knob settings, and preserves Codex's own safety behavior when Ultra is explicitly enabled. + +### 2. Change Codex's hidden global effort settings — rejected + +The current Codex bundle contains hidden settings that can remove Ultra from model controls. Using them would alter the Codex UI globally, rely on undocumented account-setting APIs, and couple the plugin to a private migration path. It would also prevent intentional manual Ultra selection. + +### 3. Enter Ultra and dismiss or confirm the dialog — rejected + +This would trigger the unwanted UI before reacting and could bypass a permission warning. It is unsafe and does not meet the goal. + +## Settings and property inspector + +`CodexDialSettings` gains a required boolean field: + +```ts +includeUltraReasoning: boolean +``` + +Normalization rules: + +- A literal boolean is preserved. +- A missing, malformed, or legacy value normalizes to `false`. +- Every preset emits the field so property-inspector and runtime normalization remain identical. +- The Reasoning preset defaults it to `false`. +- Selecting a preset replaces the field with that preset's default, consistent with existing preset behavior. + +The property inspector shows `Include Ultra` in the reasoning controls. It is enabled when the dial has a reasoning-increase binding and remains persisted when other fields change. Its help text states: `When off, clockwise reasoning stops below Ultra. Manual Codex selection is unchanged.` + +## Command flow + +Reasoning dispatch carries an explicit policy: + +```ts +{ direction: "increase" | "decrease", includeUltra: boolean } +``` + +For a dial gesture, the controller passes the dial's normalized setting. For existing non-dial callers, the bridge keeps the current unrestricted behavior unless the caller explicitly supplies the restriction. + +The target renderer performs the guard and command in one evaluation: + +1. Read the live selected model and reasoning effort from the verified composer/model state. +2. Read the active model's ordered supported reasoning efforts from the current model metadata. +3. Determine the next enabled effort. +4. If increasing would enter `ultra` while `includeUltra` is false, return `blocked-ultra` without invoking `composer.increaseReasoningEffort`. +5. Otherwise invoke exactly one allow-listed dedicated reasoning command. + +Decrease is never blocked, including when the current value is already Ultra. + +If the renderer cannot confidently identify the current effort or ordered supported list, a restricted increase fails closed rather than risk opening the dialog. Existing honest unavailable/degraded feedback remains in effect. No hidden Codex setting is mutated. + +For remote control, the authenticated relay protocol carries `includeUltra`. The remote bridge, rather than the controller-side cached snapshot, enforces the boundary against its live renderer state. Older or malformed relay messages fail closed under the existing typed command parser. + +## Dial feedback + +When an increase is blocked, Codex remains unchanged and no alert or modal is opened. The Reasoning dial briefly reports `ULTRA OFF`, then returns to authoritative live reasoning feedback. The value is never advanced optimistically. + +## Compatibility and migration + +- Existing saved dial settings without the new field normalize to `false`. +- The installed Reasoning knob will be updated to store `includeUltraReasoning: false` explicitly after the verified plugin is installed. +- Other knob gestures, keypad Reasoning Up/Down actions, Fast feedback, and encoder clicks are unchanged. +- The direct reasoning runner remains allow-listed to only the increase and decrease commands and must not be generalized to protected keycaps. + +## Verification + +Automated coverage must prove: + +- Runtime and property-inspector normalization agree for true, false, missing, and malformed values. +- The Reasoning preset and legacy settings default to false. +- A restricted increase below the ceiling issues exactly one increase command. +- A restricted increase whose next value is Ultra issues no command and returns `blocked-ultra`. +- An unrestricted increase can enter Ultra and still leaves Codex responsible for its confirmation. +- Decrease works from Ultra regardless of the checkbox. +- Missing live effort/model metadata fails closed only for restricted increases. +- Local and relay command paths carry and enforce the same policy. +- Non-dial reasoning actions retain current behavior. +- No implementation calls Codex's Ultra account-setting API or interacts with the confirmation dialog. +- Full type-check, test, Stream Deck validation, release audit, and branch diff checks pass. + +Physical QA on Stream Deck + must confirm that an unchecked Reasoning knob stops below Ultra without opening a Codex dialog, while checking the option restores Ultra and Codex's native confirmation. diff --git a/docs/superpowers/specs/2026-08-10-usage-reset-countdown-design.md b/docs/superpowers/specs/2026-08-10-usage-reset-countdown-design.md new file mode 100644 index 0000000..e14f7df --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-usage-reset-countdown-design.md @@ -0,0 +1,49 @@ +# Usage Reset Countdown Design + +**Date:** 2026-08-10 +**Status:** Approved + +## Problem + +The Usage dial currently expresses every reset interval as total hours plus minutes. A weekly reset such as 125 hours and 48 minutes appears as `RESETS IN 125H 48M`, forcing the user to convert the hours into days mentally. + +## Considered approaches + +- **Compact days, hours, and minutes — selected.** `5D 5H 48M` is immediately readable and fits the existing detail line. +- **Keep total hours — rejected.** This preserves the status quo but leaves the mental conversion problem unresolved. +- **Use full words — rejected.** `5 days, 5 hours, 48 minutes` is clearer in prose but too long for the 184-pixel dial detail region. + +## Design + +Use days, hours, and minutes whenever the remaining interval is at least 24 hours. Preserve the existing compact uppercase style and minute ceiling so the countdown never reports an earlier reset than the source timestamp. + +Examples: + +| Remaining interval | Display | +|---|---| +| 48 minutes | `RESETS IN 48M` | +| 5 hours, 48 minutes | `RESETS IN 5H 48M` | +| 24 hours | `RESETS IN 1D` | +| 24 hours, 1 minute | `RESETS IN 1D 1M` | +| 125 hours, 48 minutes | `RESETS IN 5D 5H 48M` | +| 7 days | `RESETS IN 7D` | + +Zero-value units are omitted except that an elapsed countdown remains `RESETS IN 0M`. Missing or non-finite timestamps continue to show `RESET UNAVAILABLE`. + +The same formatter applies to Auto, 5-hour, and Weekly usage modes; it is not a separate profile setting. The longest expected weekly string fits within the existing 32-character bounded detail field and 184-pixel dial detail region, so no layout or font change is required. + +## Scope + +- Update the pure reset-countdown formatter in `src/dial-domain.ts`. +- Add boundary tests for minutes, hours, exact days, mixed days/hours/minutes, elapsed resets, and unavailable values. +- Update the Stream Deck + guide with the compact day/hour/minute format. +- Do not change reset timestamps, usage-source selection, rounding, refresh behavior, rate-limit reset credits, or keypad usage artwork. + +## Verification + +- `125H 48M` becomes `5D 5H 48M`. +- Intervals below 24 hours retain the current compact hour/minute form. +- Exact zero units are omitted without leaving extra spaces. +- Existing unavailable and elapsed behavior remains unchanged. +- Full type-check, test, Stream Deck validation, release audit, and branch diff checks pass. +- Physical Stream Deck + QA confirms the weekly string fits without truncation. diff --git a/docs/superpowers/specs/2026-08-11-model-presets-dial-design.md b/docs/superpowers/specs/2026-08-11-model-presets-dial-design.md new file mode 100644 index 0000000..aa95b25 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-model-presets-dial-design.md @@ -0,0 +1,229 @@ +# Model Presets Dial Design + +**Date:** 2026-08-11 +**Status:** Approved interaction design; implementation pending +**Scope:** Add a first-class Stream Deck + dial that switches Codex model and reasoning pairs in one confirmed operation. + +## Goal + +People who usually change model and reasoning together should be able to rotate one Stream Deck + knob and immediately apply an ordered preset such as: + +1. `5.6 Sol · High` +2. `5.6 Sol · Medium` +3. `5.6 Terra · Medium` + +The dial must remain truthful: it may show progress immediately, but it may show a preset as active only after Codex confirms both the model and reasoning level. + +## Non-goals + +- Replace or remove the existing Reasoning dial. +- Type keyboard shortcuts, simulate Tab navigation, or depend on composer focus. +- Dismiss or approve Codex's native Ultra confirmation. +- Persist a copy of Codex's model catalog in Stream Deck settings. +- Add model-preset controls to the iOS client in this slice. +- Fall back to partially compatible relay behavior on older peers. + +## User experience + +### New preset + +Add **Model Presets** beside Reasoning, Agents, Actions, Navigation, Usage, and Custom in the Codex Dial property inspector. Selecting it replaces all fields for that knob with these defaults: + +- paired rotation dedicated to the ordered model-preset list; +- wrap enabled; +- press set to None; +- touch tap set to Fast Mode; +- feedback set to the active model/reasoning pair; +- Include Ultra off; +- the three preferred pairs above when they are present in the current catalog. + +When an authoritative catalog is available, omit only preferred pairs that it proves absent. If none of the preferred pairs are available, seed the list with the currently active pair when it is valid. Otherwise start with an empty list and show `NO PRESETS`. If the catalog is unavailable, do not infer defaults until authoritative data arrives. + +### Per-knob list editor + +Each Model Presets knob owns an ordered list of zero to twelve entries. Every row contains: + +- a live model dropdown populated from the currently selected host; +- a reasoning dropdown limited to that model's advertised supported levels; +- a drag handle plus keyboard-accessible move up and move down controls; +- a remove control. + +An **Add preset** control appends the first available pair not already present. Duplicate pairs are not allowed. The inspector never offers Ultra while Include Ultra is off. Turning Include Ultra off preserves existing Ultra rows but marks them unavailable; turning it on restores them when the current catalog still supports them. The runtime skips persisted Ultra entries while the policy is off. + +The catalog is transient inspector data. Persisted entries contain the stable model ID and reasoning identifier only. Display names are always resolved from the current catalog. + +When the host is offline, the inspector keeps saved rows visible, labels their current availability as unknown, and disables additions or dropdown changes that require catalog data. It never deletes saved rows because a host is temporarily unavailable. + +### Rotation behavior + +Each detent applies the next currently valid preset immediately. Clockwise moves forward and counter-clockwise moves backward. The valid list wraps continuously. + +The active pair determines the starting index when it exactly matches a configured entry. When the active pair is unlisted: + +- clockwise applies the first valid configured entry; +- counter-clockwise applies the last valid configured entry. + +Entries whose model or reasoning level is no longer in the live catalog remain saved and are marked **Unavailable** in the inspector. Rotation skips them without deleting them. Position feedback counts only currently valid entries. + +### Dial feedback + +The LCD normally shows the resolved model display name, reasoning label, and position such as `2 / 3`. When the active pair is not listed, it shows the actual pair with `UNLISTED`. Other states are: + +- `SWITCHING…` while a composite change is in flight; +- `NO PRESETS` when the list is empty or an authoritative catalog proves it contains no currently valid entries; +- the existing `DEGRADED`, `OFFLINE`, and `UNAVAILABLE` health states when authoritative data is unavailable. + +`SWITCHING…` is progress feedback, not an optimistic state change. The LCD must not show the target pair as active before confirmation. + +## Settings and migration + +Introduce dial settings version 2. The new discriminants and fields are: + +```ts +type ModelPresetEntry = { + modelId: string; + reasoningEffort: string; +}; + +type ExistingDialSettingsV2 = Omit & { + version: 2; +}; + +type ModelPresetsDialSettings = Omit & { + version: 2; + preset: "model-presets"; + rotation: { kind: "model-presets" }; + feedback: "model-presets"; + modelPresets: ModelPresetEntry[]; +}; + +type CodexDialSettingsV2 = ExistingDialSettingsV2 | ModelPresetsDialSettings; +``` + +Version 2 also adds `"model-presets"` to the preset and feedback enums and `{kind: "model-presets"}` to the rotation union. Non-Model-Presets settings keep their existing fields and behavior with `version: 2`; they never persist a `modelPresets` field. + +The production schema must use exact own data properties, bounded arrays, safe identifier validation, no symbols or accessors, and a maximum of twelve unique entries. Model IDs and reasoning identifiers use the same safe identifier grammar already enforced at the renderer and relay boundaries. + +Version 1 settings migrate without changing any existing knob's behavior. Model Presets is opt-in; migration does not convert a Reasoning knob automatically. A malformed version 2 payload fails closed to the complete defaults for its declared preset and never partially merges untrusted nested data. + +## Authoritative catalog and snapshot + +Extend the Codex Micro snapshot with optional, bounded fields for: + +- the active model ID and current display name; +- the validated model catalog, where each entry has a stable ID, display name, and ordered supported reasoning identifiers. + +Catalog extraction reuses the descriptor-safe, clone-safe query-client path already used to authorize Reasoning adjustments. It must keep the same fail-closed limits for traversal, query-key shape, own properties, safe identifiers, duplicate records, and conflicting records. A catalog is authoritative only when all consumed fields validate. + +The active model remains derived from the unique visible, non-hidden composer trigger and matched to exactly one validated catalog record. Hidden measurement labels and effort labels do not authorize a model. + +The property inspector requests the current target host's catalog through the plugin connection. The controller returns only normalized plain data associated with the current host identity and generation. Reconnects, target changes, and stale property-inspector sockets cannot overwrite newer catalog data. + +## Composite model-preset operation + +Add one bridge operation that accepts an exact `{modelId, reasoningEffort, includeUltra}` request and returns the confirmed resulting pair. + +The operation runs under the same renderer-global serialization boundary as Reasoning changes so model presets, ordinary Reasoning detents, and rapid preset detents cannot race. For each request it: + +1. Reads authoritative active metadata and the current catalog. +2. Verifies the requested pair exists in the same validated catalog record and passes the Ultra policy. +3. Resolves the unique active model-picker component's native `onSelectModel(modelId, reasoningEffort)` callback from the current installed bundle. +4. Verifies that the callback is the bounded, descriptor-safe, direct two-argument application wrapper associated with the same unique visible composer and validated catalog. +5. Invokes that callback exactly once with the requested pair. Codex then submits model and reasoning together through its native thread/default settings operation and retains its own permission and Ultra safeguards. +6. Re-resolves the active component seam while polling and confirms that its exact model ID and reasoning effort both match the request. +7. Returns only the confirmed pair. + +The current Codex 26.803.41515 bundle exposes the required native callback as the unique visible model-picker React ancestor's own `onSelectModel(modelId, effort)` data property. Its application path performs one native model-and-reasoning settings request for the active thread or host default. Implementation must still resolve and validate this seam at runtime rather than assuming a minified function name. It must require exactly one qualifying ancestor, bounded own-data descriptor traversal, the paired one-argument reasoning callback, an exact validated component catalog, strict callback arity/direct-forwarder shape, and final state confirmation. Generic DOM clicks, menu-coordinate clicks, keyboard events, direct Electron IPC, and unconfirmed state mutation are prohibited. + +Rapid detents queue in arrival order. Each queued operation resolves its target from the state confirmed by its predecessor. The controller retains the bounded command queue and backpressure behavior already used by dials. + +The controller enqueues only the detent direction and the current registration identity. It does not calculate or capture a target pair before the queued closure starts. When that closure reaches the head of the queue, it rechecks that the registration is still current, reads the latest authoritative host snapshot and catalog, and then resolves the next or previous valid pair. This prevents several rapid detents from collapsing onto the same preset. A remote command is created only after that queued target resolution. + +## Snapshot update and feedback ordering + +After a local success, the controller invalidates older local snapshot generations, immutably patches the confirmed model and reasoning pair, and redraws the same current dial registration immediately. Any pre-command poll that finishes later cannot overwrite the confirmed pair. + +After a remote success, the relay server publishes a forced fresh post-command snapshot before acknowledging the result. The client accepts feedback only for the current connection generation and exact host ID/platform identity, then immutably patches the matching snapshot. The controller redraws only the still-current registration. + +The normal 1.2-second poll remains a reconciliation path for changes made directly in Codex. + +## Relay compatibility + +Add an explicit `model-presets` capability to relay protocol version 1. A preset command includes the requested model ID, reasoning effort, Ultra policy, and confirmed-feedback opt-in. The result includes the exact confirmed model ID and reasoning effort. + +- New client to new server: allowed when the peer advertises `model-presets`. +- New client to old server: refused before `socket.send`; no partial fallback. +- Old client to new server: unchanged because it never sends the new command. +- Reconnect or host-generation change: rejects pending preset commands and prevents stale patches. + +The parser requires exact own data fields and the shared safe identifier grammar. Unknown, missing, accessor-backed, symbolic, oversized, or extra fields fail closed. + +## Error behavior + +If validation fails before model selection, send no command and keep the last authoritative feedback. + +An absent, stale, or generation-mismatched catalog is not evidence that saved entries are invalid. In that state the dial shows `UNAVAILABLE`, sends no command, and makes no deletion or skip decision until an authoritative catalog returns. `NO PRESETS` is reserved for an empty saved list or a current authoritative catalog proving that every saved entry is invalid. + +After the paired callback has been invoked, failure to confirm the exact requested pair is an uncertain result. This includes a partial model-only or effort-only state, a timeout, or an unreadable post-state. Do not attempt an automatic rollback because a rollback could itself target a stale composer or unsupported pair. Instead: + +1. reserve uncertainty in the shared renderer guard; +2. force a fresh snapshot; +3. show the actual resulting pair as `UNLISTED` when available; +4. show a brief error notice; +5. require later authoritative metadata before another protected composite change proceeds. + +Transport timeouts rotate the renderer guard namespace on disconnect so an abandoned evaluation cannot poison later reconnects. Property-inspector and dial failures must not create unhandled promise rejections or leave stale timers that can overwrite a newer registration. + +## Testing and QA + +Use test-driven slices with a failing regression before each production change. + +### Domain and settings + +- version 1 migration preserves every existing preset; +- exact version 2 normalization and malformed nested payload rejection; +- zero, one, and twelve entries; thirteen rejected; +- duplicate, unsafe, accessor, symbol, and oversized entries rejected; +- wrap, reverse wrap, unlisted entry, and invalid-entry skipping; +- Ultra visibility and runtime refusal when disabled. + +### Property inspector + +- live dropdowns use only the current host/generation catalog; +- supported efforts update with model selection; +- add, remove, drag reorder, keyboard reorder, cap, duplicate prevention, and accessibility; +- offline saved-row preservation; +- stale WebSocket and pre-registration edit safety; +- complete settings persistence and restart restoration. + +### Renderer bridge + +- live catalog extraction and active-model matching; +- exact application-level model selection with no keyboard/focus navigation; +- exactly one native model-and-reasoning callback invocation per preset change; +- re-resolved component state confirmation of the final pair; +- serialized rapid requests that resolve direction only at queue execution time, plus mixed Reasoning/preset requests; +- partial failure, uncertainty, timeout, reconnect, and late response behavior; +- hostile getters, proxies, symbols, conflicting catalogs, and traversal bounds. + +### Controller and relay + +- immediate local confirmed patch with stale-poll fencing; +- same-registration redraw and replacement/disposal safety; +- `SWITCHING…`, success, unlisted, partial-error, and health feedback; +- capability negotiation and old/new compatibility matrix; +- server forced-snapshot/result ordering; +- host ID, platform, and connection-generation fencing; +- strict command/result/snapshot grammar. + +### Release and physical QA + +- full typecheck, unit suite, Stream Deck validation, release audit, and diff check; +- read-only live Codex probe that confirms catalog extraction and resolver availability without invoking model selection; +- timestamped backup of the plugin, watcher runtime, and Stream Deck + profile; +- install on the real Mac without restarting a healthy Codex session; +- physically verify the three preferred presets, both wrap directions, rapid detents, Fast touch, restart persistence, and unavailable-entry behavior. + +## Delivery + +Implement in independently reviewable commits, with subagents performing code review and QA between slices. After all checks and physical verification pass, fast-forward the feature branch into local `main`, retain the branch for a later pull request, install the approved build, and do not push remotely unless explicitly requested. diff --git a/docs/superpowers/specs/2026-08-16-nondestructive-macos-bridge-recovery-design.md b/docs/superpowers/specs/2026-08-16-nondestructive-macos-bridge-recovery-design.md new file mode 100644 index 0000000..2001e92 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-nondestructive-macos-bridge-recovery-design.md @@ -0,0 +1,39 @@ +# Non-Destructive macOS Bridge Recovery Design + +## Problem + +Codex 26.810.52044 no longer exposes the Statsig client expected by the installed Micro runtime override. The watcher interprets the missing bridge as recoverable, sends `SIGTERM` to the running Codex process, and relaunches it. Repeated activation failure therefore causes repeated data-disrupting application restarts while Stream Deck remains `DEGRADED`. + +## Safety Contract + +- The background watcher never signals, terminates, or launches Codex. +- A missing or incompatible bridge is reported as degraded and retried in place. +- Closing Codex intentionally leaves it closed. +- Only an explicit user-invoked launcher command may perform a controlled restart to add the loopback debugging flags. +- Existing Stream Deck profiles, plugin settings, Codex sessions, and relay configuration remain untouched. + +## Runtime Compatibility + +The runtime override must treat Statsig as optional. When a current build has no Statsig client, it continues discovery of Codex's already-loaded native Micro event bus, dispatches the native connected-device event through that bus, and verifies the actual HID and joystick handlers. Older builds continue to receive the gate override when Statsig is available. + +Readiness is based on the runtime behavior the plugin needs: a native event bus, a dispatched or persisted device signal, and registered HID/joystick handlers. Missing Statsig alone is not failure. Missing event-bus authority or handlers remains fail-closed and leaves the LCD degraded. + +## Watcher Behavior + +The watcher policy returns only preserve, reuse, or wait decisions. Once Codex is running without a healthy bridge, it records a stable degraded state and continues polling. The watcher implementation contains no recovery branch capable of calling `terminateCodex` or `launchCodex`. + +The explicit `start` command retains controlled restart behavior because the user invoked it for that purpose. Installation and diagnostics clearly distinguish this from background recovery. + +## Verification + +Automated tests must prove: + +- prior healthy bridge loss never produces a restart action; +- app-generation replacement never produces a restart action; +- prolonged stable bridge loss never produces a restart action; +- the watcher source has no automatic terminate/relaunch path; +- no-Statsig runtime activation continues through the native event bus; +- older Statsig-backed activation remains supported; +- missing event-bus handlers remains unavailable. + +Live verification uses a single explicit controlled restart, then checks the bridge state file, loopback listener, plugin logs, `local=ready`, LCD recovery, and process stability. The LaunchAgent is re-enabled only after the installed watcher is proven non-destructive. diff --git a/ios/CodexDeckMobile/Models/RelayModels.swift b/ios/CodexDeckMobile/Models/RelayModels.swift index ac5c1f4..2d8b1eb 100644 --- a/ios/CodexDeckMobile/Models/RelayModels.swift +++ b/ios/CodexDeckMobile/Models/RelayModels.swift @@ -275,6 +275,7 @@ enum RelayCommand: Encodable, Sendable { case .reasoning(let direction): try values.encode("reasoning", forKey: .kind) try values.encode(direction, forKey: .direction) + try values.encode(true, forKey: .includeUltra) case .rateLimitReset: try values.encode("rate-limit-reset", forKey: .kind) case .keycap(let id): @@ -284,7 +285,7 @@ enum RelayCommand: Encodable, Sendable { } private enum CodingKeys: String, CodingKey { - case kind, slot, threadKey, act, direction, distance, keycapId + case kind, slot, threadKey, act, direction, distance, includeUltra, keycapId } } diff --git a/ios/CodexDeckMobileTests/MobileMergeTests.swift b/ios/CodexDeckMobileTests/MobileMergeTests.swift index a3bd29a..47a5619 100644 --- a/ios/CodexDeckMobileTests/MobileMergeTests.swift +++ b/ios/CodexDeckMobileTests/MobileMergeTests.swift @@ -3,6 +3,17 @@ import XCTest @testable import CodexDeckMobile final class MobileMergeTests: XCTestCase { + func testReasoningCommandEncodesExplicitUnrestrictedUltraPolicy() throws { + let data = try JSONEncoder().encode(RelayCommand.reasoning(direction: "increase")) + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(payload["kind"] as? String, "reasoning") + XCTAssertEqual(payload["direction"] as? String, "increase") + XCTAssertEqual(payload["includeUltra"] as? Bool, true) + XCTAssertEqual(Set(payload.keys), Set(["kind", "direction", "includeUltra"])) + } + func testDeduplicatesMirroredThreadAndRoutesToRolloutOwner() throws { let windows = host("win", .win32) let mac = host("mac", .darwin) diff --git a/launcher/macos/codex-deck-macos.ts b/launcher/macos/codex-deck-macos.ts index 4500b16..284f130 100644 --- a/launcher/macos/codex-deck-macos.ts +++ b/launcher/macos/codex-deck-macos.ts @@ -460,12 +460,6 @@ async function runWatcher(): Promise { } else { enabledSignature = ""; await removeStaleBridgeState(null, log); - if (decision.action.type === "restart-for-recovery" && main) { - await log(`Recovering Codex bridge once for generation ${main.generation} (${decision.action.reason}).`); - await terminateCodex(main); - const refreshed = await discoverCodexInstallation(); - await launchCodex(refreshed, await chooseLoopbackPort()); - } } } catch (error) { await log(`Watcher iteration failed: ${String(error)}`); @@ -484,22 +478,92 @@ set -u runtime=${shellQuote(runtimePath)} typeset -a candidates -candidates=(/opt/homebrew/bin/node /usr/local/bin/node) +candidates=( + /opt/homebrew/bin/node + /usr/local/bin/node + /Applications/Codex.app/Contents/Resources/cua_node/bin/node + /Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node + "$HOME"/Applications/Codex.app/Contents/Resources/cua_node/bin/node + "$HOME"/Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node +) for node_candidate in "$HOME"/.nvm/versions/node/*/bin/node(N); do candidates+=("$node_candidate") done -for app_candidate in \${(f)"$(/usr/bin/mdfind 'kMDItemCFBundleIdentifier == "com.openai.codex"' 2>/dev/null)"}; do - candidates+=("$app_candidate/Contents/Resources/cua_node/bin/node") -done -for node_candidate in "\${candidates[@]}"; do - [[ -x "$node_candidate" ]] || continue - node_version=$("$node_candidate" --version 2>/dev/null) || continue +launch_with_node() { + local node_candidate="$1" + local node_version node_major + [[ -x "$node_candidate" ]] || return 0 + node_version=$("$node_candidate" --version 2>/dev/null) || return 0 node_major=\${\${node_version#v}%%.*} - [[ "$node_major" == <-> && "$node_major" -ge 20 ]] || continue + [[ "$node_major" == <-> && "$node_major" -ge 20 ]] || return 0 exec "$node_candidate" "$runtime" watch +} + +for node_candidate in "\${candidates[@]}"; do + launch_with_node "$node_candidate" done +spotlight_pid="" +spotlight_results="" +cleanup_spotlight() { + trap - TERM INT HUP EXIT + if [[ -n "$spotlight_pid" ]] && /bin/kill -0 "$spotlight_pid" 2>/dev/null; then + /bin/kill -TERM "$spotlight_pid" 2>/dev/null + /bin/sleep 0.1 + if /bin/kill -0 "$spotlight_pid" 2>/dev/null; then + /bin/kill -KILL "$spotlight_pid" 2>/dev/null + fi + fi + if [[ -n "$spotlight_pid" ]]; then + wait "$spotlight_pid" 2>/dev/null + spotlight_pid="" + fi + if [[ -n "$spotlight_results" ]]; then + /bin/rm -f "$spotlight_results" + spotlight_results="" + fi +} +trap 'cleanup_spotlight; exit 143' TERM +trap 'cleanup_spotlight; exit 130' INT +trap 'cleanup_spotlight; exit 129' HUP +trap cleanup_spotlight EXIT + +spotlight_results=$(/usr/bin/mktemp "\${TMPDIR:-/tmp}/codex-deck-mdfind.XXXXXX" 2>/dev/null) || spotlight_results="" +if [[ -n "$spotlight_results" ]]; then + /usr/bin/mdfind 'kMDItemCFBundleIdentifier == "com.openai.codex"' > "$spotlight_results" 2>/dev/null & + spotlight_pid=$! + typeset -i spotlight_checks=0 + while /bin/kill -0 "$spotlight_pid" 2>/dev/null && (( spotlight_checks < 20 )); do + /bin/sleep 0.1 + (( spotlight_checks += 1 )) + done + if /bin/kill -0 "$spotlight_pid" 2>/dev/null; then + /bin/kill -TERM "$spotlight_pid" 2>/dev/null + /bin/sleep 0.1 + if /bin/kill -0 "$spotlight_pid" 2>/dev/null; then + /bin/kill -KILL "$spotlight_pid" 2>/dev/null + fi + fi + wait "$spotlight_pid" 2>/dev/null + spotlight_status=$? + spotlight_pid="" + typeset -a spotlight_candidates + spotlight_candidates=() + if [[ "$spotlight_status" -eq 0 ]]; then + while IFS= read -r app_candidate; do + [[ -n "$app_candidate" ]] || continue + spotlight_candidates+=("$app_candidate/Contents/Resources/cua_node/bin/node") + done < "$spotlight_results" + fi + /bin/rm -f "$spotlight_results" + spotlight_results="" + for node_candidate in "\${spotlight_candidates[@]}"; do + launch_with_node "$node_candidate" + done +fi +trap - TERM INT HUP EXIT + print -r -- "$(/bin/date -u +%Y-%m-%dT%H:%M:%SZ) [launcher] Node.js 20 or newer was not found; watcher did not start." >> ${shellQuote(WATCHER_LOG_PATH)} exit 78 `; @@ -672,10 +736,10 @@ async function selfTest(): Promise { assert.equal(result.action.type, "wait", "a new process must remain stable before recovery"); state = result.state; result = evaluateWatcherPolicy(state, { now: 30_001, generation: "B", bridgeHealthy: false }); - assert.equal(result.action.type, "restart-for-recovery", "a stable new process can be recovered once"); + assert.deepEqual(result.action, { type: "wait", reason: "bridge-unavailable-degraded" }, "a stable new process remains degraded without a restart"); state = result.state; result = evaluateWatcherPolicy(state, { now: 61_000, generation: "C", bridgeHealthy: false }); - assert.equal(result.action.type, "wait", "the global circuit breaker blocks a new-generation restart loop"); + assert.equal(result.action.type, "wait", "a new generation also remains untouched"); state = createWatcherPolicyState(0); result = evaluateWatcherPolicy(state, { now: 0, generation: null, bridgeHealthy: false }); @@ -690,7 +754,7 @@ async function selfTest(): Promise { assert.equal(result.action.type, "wait", "a replacement process must remain stable before recovery"); state = result.state; result = evaluateWatcherPolicy(state, { now: 30_000, generation: "B", bridgeHealthy: false }); - assert.equal(result.action.type, "restart-for-recovery", "a previous healthy bridge recovers after a stable replacement"); + assert.deepEqual(result.action, { type: "wait", reason: "bridge-unavailable-degraded" }, "a previous healthy bridge never authorizes background restart"); state = createWatcherPolicyState(0); result = evaluateWatcherPolicy(state, { now: 0, generation: null, bridgeHealthy: false }); @@ -716,7 +780,7 @@ async function selfTest(): Promise { assert.equal(isBridgeStateStale(70_000, null), true, "stale/invalid port state is rejected"); assert.equal(isBridgeStateStale(43123, 43123), false, "the active bridge state is retained"); - console.log("macOS self-test passed: safe recovery, circuit-breaker, race, stale-state, and single-instance scenarios."); + console.log("macOS self-test passed: non-destructive degradation, race, stale-state, and single-instance scenarios."); } async function main(): Promise { diff --git a/launcher/macos/watcher-policy.ts b/launcher/macos/watcher-policy.ts index be848f9..aba039a 100644 --- a/launcher/macos/watcher-policy.ts +++ b/launcher/macos/watcher-policy.ts @@ -7,8 +7,7 @@ export type WatcherObservation = { export type WatcherAction = | { type: "preserve-initial-session" } | { type: "reuse-bridge" } - | { type: "wait"; reason: string } - | { type: "restart-for-recovery"; generation: string; reason: string }; + | { type: "wait"; reason: string }; export type WatcherPolicyState = { initialized: boolean; @@ -134,34 +133,8 @@ export function evaluateWatcherPolicy( return { state: next, action: { type: "preserve-initial-session" } }; } - if (now < next.recoveryCooldownUntil) { - return { state: next, action: { type: "wait", reason: "automatic-recovery-circuit-open" } }; - } - - if (next.recoveryAttempts.includes(generation)) { - return { state: next, action: { type: "wait", reason: "recovery-already-attempted-for-generation" } }; - } - if (next.unbridgedSince == null || now - next.unbridgedSince < DEFAULT_UNBRIDGED_STABLE_MS) { return { state: next, action: { type: "wait", reason: "confirm-stable-unbridged-generation" } }; } - - const shouldRecover = generationChanged || observedStoppedInterval || next.hadHealthyBridge || now >= next.startupGraceUntil; - if (!shouldRecover) { - return { state: next, action: { type: "wait", reason: "launch-agent-startup-grace" } }; - } - - next.recoveryAttempts = [...next.recoveryAttempts.slice(-15), generation]; - next.recoveryPendingUntil = now + DEFAULT_RECOVERY_STARTUP_MS; - next.recoveryCooldownUntil = now + DEFAULT_RECOVERY_COOLDOWN_MS; - next.unbridgedGeneration = null; - next.unbridgedSince = null; - const reason = generationChanged - ? "main-process-generation-changed" - : observedStoppedInterval - ? "normal-launch-after-stopped-interval" - : next.hadHealthyBridge - ? "previous-healthy-bridge-missing" - : "normal-launch-after-startup-grace"; - return { state: next, action: { type: "restart-for-recovery", generation, reason } }; + return { state: next, action: { type: "wait", reason: "bridge-unavailable-degraded" } }; } diff --git a/launcher/runtime-override.ts b/launcher/runtime-override.ts index 435d7fc..af08dc7 100644 --- a/launcher/runtime-override.ts +++ b/launcher/runtime-override.ts @@ -11,10 +11,7 @@ export function buildRuntimeOverrideExpression(gateName = MICRO_GATE): string { return `(async () => { const gateName = ${JSON.stringify(gateName)}; const statsig = globalThis.__STATSIG__; - if (!statsig) return { ready: false, reason: 'statsig-unavailable' }; - - const clients = [...new Set([statsig.firstInstance, ...Object.values(statsig.instances ?? {})].filter(Boolean))]; - if (clients.length === 0) return { ready: false, reason: 'statsig-client-unavailable' }; + const clients = [...new Set([statsig?.firstInstance, ...Object.values(statsig?.instances ?? {})].filter(Boolean))]; for (const client of clients) { if (client.overrideAdapter?.__codexDeckGate !== gateName) { @@ -91,8 +88,9 @@ export function buildRuntimeOverrideExpression(gateName = MICRO_GATE): string { } const enabled = clients.map((client) => Boolean(client.checkGate?.(gateName))); + const gateReady = clients.length === 0 || enabled.every(Boolean); return { - ready: enabled.every(Boolean) && (detected === true || deviceEventDispatched), + ready: gateReady && (detected === true || deviceEventDispatched), enabled, detected, detectionMethod, @@ -128,8 +126,9 @@ export function buildRuntimeVerificationExpression(): string { const statsig = globalThis.__STATSIG__; const clients = [...new Set([statsig?.firstInstance, ...Object.values(statsig?.instances ?? {})].filter(Boolean))]; const menuEnabled = settingsLink || (clients.length > 0 && clients.every((client) => Boolean(client.checkGate?.(${JSON.stringify(MICRO_GATE)})))); + const handlersReady = Boolean(bus) && hidHandlers > 0 && joystickHandlers > 0; return { - ready: menuEnabled && Boolean(bus) && hidHandlers > 0 && joystickHandlers > 0, + ready: handlersReady && (menuEnabled || clients.length === 0), menuEnabled, nativeEventBus: Boolean(bus), hidHandlers, diff --git a/scripts/audit-release.mjs b/scripts/audit-release.mjs index 2f6de5d..3e990cd 100644 --- a/scripts/audit-release.mjs +++ b/scripts/audit-release.mjs @@ -1,5 +1,5 @@ -import { readFile, readdir, stat } from "node:fs/promises"; -import { basename, extname, resolve } from "node:path"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import { basename, extname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; const roots = process.argv.slice(2).length ? process.argv.slice(2).map((path) => resolve(path)) @@ -18,13 +18,21 @@ const forbiddenText = [ ...String(process.env.CODEX_DECK_PRIVATE_MARKERS ?? "").split("|").filter(Boolean).map((marker) => new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu")) ]; const textExtensions = new Set([".cmd", ".command", ".html", ".js", ".json", ".map", ".md", ".mjs", ".ps1", ".sh", ".svg", ".txt"]); +const codexDialUuid = "com.simeo.codex-deck.codex-dial"; const failures = []; async function walk(path) { - const info = await stat(path); + const info = await lstat(path); + if (info.isSymbolicLink()) { + failures.push(`${path}: symlink must not be packaged`); + return false; + } if (info.isDirectory()) { - for (const entry of await readdir(path)) await walk(resolve(path, entry)); - return; + let safe = true; + for (const entry of await readdir(path)) { + if (!await walk(resolve(path, entry))) safe = false; + } + return safe; } const name = basename(path); if (name === ".DS_Store" || name.startsWith("._")) failures.push(`${path}: platform metadata must not be packaged`); @@ -32,13 +40,109 @@ async function walk(path) { if (extname(name).toLowerCase() === ".svg" && protectedKeycaps.has(name.slice(0, -4).toUpperCase())) { failures.push(`${path}: protected Codex keycap SVG must not be packaged`); } - if (!textExtensions.has(extname(name).toLowerCase()) || info.size > 8 * 1024 * 1024) return; + if (!textExtensions.has(extname(name).toLowerCase()) || info.size > 8 * 1024 * 1024) return true; const contents = await readFile(path, "utf8"); for (const pattern of forbiddenText) if (pattern.test(contents)) failures.push(`${path}: contains private setup marker ${pattern}`); + return true; +} + +async function readPluginManifest(root) { + const manifestPath = resolve(root, "manifest.json"); + const pluginRoot = basename(root).toLowerCase().endsWith(".sdplugin"); + let source; + try { source = await readFile(manifestPath, "utf8"); } + catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + if (pluginRoot) failures.push(`${manifestPath}: plugin manifest is required for a .sdPlugin root`); + return undefined; + } + failures.push(`${manifestPath}: cannot read plugin manifest (${String(error)})`); + return undefined; + } + let manifest; + try { manifest = JSON.parse(source); } + catch { + failures.push(`${manifestPath}: plugin manifest is not valid JSON`); + return undefined; + } + if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) { + failures.push(`${manifestPath}: plugin manifest must be a JSON object`); + return undefined; + } + if (!Array.isArray(manifest.Actions)) { + failures.push(`${manifestPath}: plugin manifest Actions must be an array`); + return undefined; + } + return manifest; +} + +async function auditDeclaredCodexDialAssets(root) { + const manifest = await readPluginManifest(root); + if (!manifest) return; + const actions = manifest.Actions.filter((action) => + action && typeof action === "object" && !Array.isArray(action) && action.UUID === codexDialUuid + ); + if (actions.length !== 1) { + failures.push(`${root}: plugin manifest must declare exactly one Codex Dial action`); + return; + } + const action = actions[0]; + if (!action.Encoder || typeof action.Encoder !== "object" || Array.isArray(action.Encoder)) { + failures.push(`${root}: Codex Dial Encoder must be an object`); + return; + } + const inspector = safeDeclaredPath(root, action.PropertyInspectorPath, "PropertyInspectorPath"); + const layout = safeDeclaredPath(root, action.Encoder.layout, "Encoder.layout"); + const icon = safeDeclaredPath(root, action.Encoder.Icon, "Encoder.Icon", true); + if (!inspector || !layout || !icon) return; + await Promise.all([ + auditDeclaredFile(root, inspector, "PropertyInspectorPath"), + auditDeclaredFile(root, layout, "Encoder.layout"), + auditDeclaredFile(root, { path: `${icon.path}.svg`, absolute: `${icon.absolute}.svg` }, "Encoder.Icon"), + auditDeclaredFile(root, { path: `${icon.path}@2x.svg`, absolute: `${icon.absolute}@2x.svg` }, "Encoder.Icon") + ]); +} + +function safeDeclaredPath(root, value, field, extensionless = false) { + const suffix = extensionless ? " extensionless" : ""; + const invalid = () => { + failures.push(`${root}: Codex Dial ${field} must be a non-empty safe relative${suffix} path`); + return undefined; + }; + if (typeof value !== "string" || value.length === 0 || value.trim() !== value || + value.includes("\\") || value.includes(":")) return invalid(); + if (posix.isAbsolute(value) || win32.isAbsolute(value)) return invalid(); + const normalized = posix.normalize(value); + if (normalized !== value || normalized === "." || normalized === ".." || normalized.startsWith("../")) { + return invalid(); + } + if (extensionless && posix.extname(value) !== "") return invalid(); + const absolute = resolve(root, value); + const fromRoot = relative(root, absolute); + if (!fromRoot || isAbsolute(fromRoot) || fromRoot === ".." || fromRoot.startsWith(`..${sep}`)) { + return invalid(); + } + return { path: value, absolute }; +} + +async function auditDeclaredFile(root, declared, field) { + try { + const info = await lstat(declared.absolute); + if (info.isSymbolicLink()) { + failures.push(`${declared.absolute}: symlink must not be packaged (declared by ${field})`); + return; + } + if (!info.isFile()) throw new Error("not a file"); + } catch { + failures.push(`${root}: missing packaged Codex Dial asset: ${declared.path} (declared by ${field})`); + } } for (const root of roots) { - try { await walk(root); } + try { + const symlinkFree = await walk(root); + if (symlinkFree) await auditDeclaredCodexDialAssets(root); + } catch (error) { failures.push(`${root}: cannot audit (${String(error)})`); } } diff --git a/scripts/build.mjs b/scripts/build.mjs index 848c8a8..fd19c11 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -14,9 +14,11 @@ for (const entry of await readdir(output)) { } await mkdir(resolve(output, "bin"), { recursive: true }); await mkdir(resolve(output, "static/imgs"), { recursive: true }); +await mkdir(resolve(output, "static/layouts"), { recursive: true }); await mkdir(resolve(output, "static/property-inspector"), { recursive: true }); for (const filename of [ "category-icon.svg", "category-icon@2x.svg", + "dial.svg", "dial@2x.svg", "key.svg", "key@2x.svg", "plugin-icon.png", "plugin-icon@2x.png" ]) { @@ -25,6 +27,8 @@ for (const filename of [ await cp(resolve("static/manifest.json"), resolve(output, "manifest.json")); await cp(resolve("static/property-inspector/usage-limit.html"), resolve(output, "static/property-inspector/usage-limit.html")); await cp(resolve("static/property-inspector/agent.html"), resolve(output, "static/property-inspector/agent.html")); +await cp(resolve("static/property-inspector/codex-dial.html"), resolve(output, "static/property-inspector/codex-dial.html")); +await cp(resolve("static/layouts/codex-dial.json"), resolve(output, "static/layouts/codex-dial.json")); await build({ entryPoints: [resolve("src/plugin.ts")], diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index 36c209e..676bd14 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -7,7 +7,14 @@ import WebSocket from "ws"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; import { OFFICIAL_KEYCAP_IDS, type OfficialKeycapId } from "./keycaps.js"; import { CodexSessionOwnershipIndex } from "./session-ownership.js"; -import type { MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment } from "./types.js"; +import { isSafeReasoningIdentifier } from "./types.js"; +import type { + CodexModelCatalogEntry, MicroActionSlot, MicroDirection, MicroSnapshot, ModelPresetExecution, ModelPresetRequest, + ReasoningAdjustment, ReasoningAdjustmentPolicy, + ReasoningAdjustmentExecution, ReasoningAdjustmentResult, UsageSnapshot, UsageWindow +} from "./types.js"; + +export { isSafeReasoningIdentifier }; type DebugTarget = { type: string; @@ -56,6 +63,54 @@ export function resolveAgentDispatch( : { kind: "direct", threadKey }; } +type CommandRunner = (command: string, source: string) => unknown; + +const REASONING_COMMANDS = Object.freeze([ + "composer.increaseReasoningEffort", + "composer.decreaseReasoningEffort" +] as const); + +export async function resolveCommandRunner( + bridgeSource: string, + bridgeUrl: string, + importModule: (url: string) => Promise> +): Promise { + const guardedCall = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(\s*(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)\s*,\s*(?:"codex_micro_hid"|'codex_micro_hid'|`codex_micro_hid`)\s*\)/g; + const runnerLocals = new Set(); + let callMatch: RegExpExecArray | null; + while ((callMatch = guardedCall.exec(bridgeSource))) runnerLocals.add(callMatch[1]!); + if (runnerLocals.size === 0) return null; + + const importPattern = /import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g; + let importMatch: RegExpExecArray | null; + while ((importMatch = importPattern.exec(bridgeSource))) { + for (const specifier of importMatch[1]!.split(',')) { + const parts = specifier.trim().split(/\s+as\s+/); + const exportName = parts[0]; + const runnerLocal = parts[1] ?? exportName; + if (!exportName || !runnerLocal || parts.length > 2 || + !/^[A-Za-z_$][\w$]*$/.test(exportName) || !/^[A-Za-z_$][\w$]*$/.test(runnerLocal) || + !runnerLocals.has(runnerLocal)) continue; + try { + const namespace = await importModule(new URL(importMatch[2]!, bridgeUrl).href); + const candidate = namespace[exportName]; + if (typeof candidate === "function") return candidate as CommandRunner; + } catch {} + } + } + return null; +} + +export async function resolveKeycapCommandRunner( + command: string, + bridgeSource: string, + bridgeUrl: string, + importModule: (url: string) => Promise> +): Promise { + if (command === "composer.increaseReasoningEffort" || command === "composer.decreaseReasoningEffort") return null; + return resolveCommandRunner(bridgeSource, bridgeUrl, importModule); +} + const execFileAsync = promisify(execFile); const PORT_FILE = join(codexDeckStateRoot(), "codex-micro-bridge.json"); const DEVICE_STATE = { @@ -63,12 +118,920 @@ const DEVICE_STATE = { state: { status: "connected", error: null, battery: { percentage: 100, isCharging: true } } }; -export const REASONING_ENCODER_KEYS: Record = { - decrease: "ENC_CW", - increase: "ENC_CC" +type RendererUsageQuery = { + state?: { data?: unknown; dataUpdatedAt?: number }; + fetch?: () => unknown; +}; + +export async function readUsageQueryData( + query: RendererUsageQuery | undefined, + forceUsageRefresh: boolean, + now = Date.now(), + refreshState = globalThis as unknown as Record +): Promise { + if (!query) return undefined; + if (forceUsageRefresh) { + if (typeof query.fetch !== "function") throw new Error("Codex usage query cannot be refreshed."); + await Promise.resolve(query.fetch()); + } else { + const refreshKey = Symbol.for('codex-deck-rate-limit-refresh-at'); + const dataUpdatedAt = Number(query.state?.dataUpdatedAt) || 0; + const lastRefreshAttempt = Number(refreshState[refreshKey]) || 0; + if (typeof query.fetch === "function" && now - dataUpdatedAt >= 15000 && now - lastRefreshAttempt >= 15000) { + refreshState[refreshKey] = now; + try { Promise.resolve(query.fetch()).catch(() => {}); } catch {} + } + } + return query.state?.data; +} + +export function normalizeRendererUsage( + data: unknown, + dataUpdatedAt?: number, + now = Date.now() +): UsageSnapshot | undefined { + if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; + const record = data as Record; + const rateLimit = record.rate_limit; + if (!rateLimit || typeof rateLimit !== "object" || Array.isArray(rateLimit)) return undefined; + const rateLimitRecord = rateLimit as Record; + const toEpoch = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value < 100000000000 ? value * 1000 : value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; + }; + const normalizeWindow = (value: unknown, role: string): UsageWindow | null => { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const window = value as Record; + const used = window.used_percent; + if (typeof used !== "number" || !Number.isFinite(used) || used < 0 || used > 100) return null; + const hasSeconds = Object.prototype.hasOwnProperty.call(window, "limit_window_seconds"); + const seconds = window.limit_window_seconds; + if (hasSeconds && (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)) return null; + const minutes = typeof seconds === "number" ? seconds / 60 : null; + const resetValue = window.reset_at; + const resetsAt = resetValue == null ? null : toEpoch(resetValue); + if (resetValue != null && resetsAt == null) return null; + const kind: UsageWindow["kind"] = minutes != null && Math.abs(minutes - 300) <= 1 ? "five-hour" + : minutes != null && Math.abs(minutes - 10080) <= 1 ? "weekly" + : "other"; + return { + id: kind === "other" ? `${role}-${String(minutes ?? "unknown")}` : kind, + kind, + usedPercent: used, + remainingPercent: 100 - used, + windowDurationMins: minutes, + resetsAt: resetsAt ?? null + }; + }; + const windows = [ + normalizeWindow(rateLimitRecord.primary_window, "primary"), + normalizeWindow(rateLimitRecord.secondary_window, "secondary") + ].filter((window): window is NonNullable => window != null); + if (windows.length === 0) return undefined; + const credits = record.rate_limit_reset_credits; + const creditRecord = credits && typeof credits === "object" && !Array.isArray(credits) + ? credits as Record + : {}; + const normalizeCredit = (key: string): { valid: boolean; value: number | null } => { + if (!Object.prototype.hasOwnProperty.call(creditRecord, key)) return { valid: true, value: null }; + const value = creditRecord[key]; + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? { valid: true, value } + : { valid: false, value: null }; + }; + const available = normalizeCredit("available_count"); + const applicable = normalizeCredit("applicable_available_count"); + if (!available.valid || !applicable.valid) return undefined; + return { + windows, + observedAt: typeof dataUpdatedAt === "number" && Number.isFinite(dataUpdatedAt) && dataUpdatedAt > 0 + ? dataUpdatedAt + : now, + resetCreditsAvailable: available.value, + resetCreditsApplicable: applicable.value + }; +} + +function hasValidNormalizedUsage(usage: UsageSnapshot | undefined): usage is UsageSnapshot { + if (!usage || !Array.isArray(usage.windows) || usage.windows.length === 0 || usage.windows.length > 8) return false; + if (!Number.isFinite(usage.observedAt) || usage.observedAt <= 0) return false; + const validCredit = (value: number | null): boolean => value == null || (Number.isSafeInteger(value) && value >= 0); + if (!validCredit(usage.resetCreditsAvailable) || !validCredit(usage.resetCreditsApplicable)) return false; + return usage.windows.every((window) => + typeof window.id === "string" && window.id.length > 0 && window.id.length <= 64 && + ["five-hour", "weekly", "other"].includes(window.kind) && + Number.isFinite(window.usedPercent) && window.usedPercent >= 0 && window.usedPercent <= 100 && + Number.isFinite(window.remainingPercent) && window.remainingPercent >= 0 && window.remainingPercent <= 100 && + (window.windowDurationMins == null || (Number.isFinite(window.windowDurationMins) && window.windowDurationMins > 0)) && + (window.resetsAt == null || (Number.isFinite(window.resetsAt) && window.resetsAt > 0)) + ); +} + +type ReasoningTriggerElement = { + isConnected?: boolean; + getClientRects?: () => { length: number }; + getAttribute: (name: string) => string | null; + querySelector?: (selectors: string) => unknown; + querySelectorAll?: (selectors: string) => ArrayLike; +}; + +export function isVisibleReasoningTrigger(element: ReasoningTriggerElement): boolean { + if (element.isConnected === false || (element.getClientRects?.().length ?? 0) === 0) return false; + const style = getComputedStyle(element as unknown as Element); + return style.display !== "none" && style.visibility !== "hidden"; +} + +export function readActiveReasoningEffort( + elements: Iterable, + isVisible = isVisibleReasoningTrigger +): string | undefined { + const candidates = new Set(); + for (const element of elements) { + if (!isVisible(element)) continue; + if (element.getAttribute("data-composer-navigation-target") !== "reasoning") continue; + const value = element.getAttribute("data-selected-reasoning-effort"); + if (isSafeReasoningIdentifier(value)) candidates.add(value); + } + return candidates.size === 1 ? candidates.values().next().value : undefined; +} + +export function readConfirmedReasoningEffort( + elements: Iterable, + isVisible = isVisibleReasoningTrigger +): string | undefined { + try { + let candidate: string | undefined; + for (const element of elements) { + if (!isVisible(element) || + element.getAttribute("data-composer-navigation-target") !== "reasoning") continue; + if (candidate !== undefined) return undefined; + const value = element.getAttribute("data-selected-reasoning-effort")?.trim(); + if (!isSafeReasoningIdentifier(value)) return undefined; + candidate = value; + } + return candidate; + } catch { return undefined; } +} + +export function readOwnDataProperty( + object: unknown, + key: string +): { exists: boolean; value?: unknown } | undefined { + if (!object || (typeof object !== "object" && typeof object !== "function")) return { exists: false }; + try { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor) return { exists: false }; + if (!Object.prototype.hasOwnProperty.call(descriptor, "value")) return undefined; + return { exists: true, value: descriptor.value }; + } catch { return undefined; } +} + +export function readDataPropertyInPrototypeChain( + object: unknown, + key: string, + maxDepth = 16 +): { exists: boolean; value?: unknown } | undefined { + if (!object || (typeof object !== "object" && typeof object !== "function")) return { exists: false }; + try { + let current: object | null = object as object; + for (let depth = 0; current && depth <= maxDepth; depth++) { + const property = readOwnDataProperty(current, key); + if (!property) return undefined; + if (property.exists) return property; + current = Object.getPrototypeOf(current); + } + return current ? undefined : { exists: false }; + } catch { return undefined; } +} + +export function isCloneableReasoningQueryClient(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + try { + const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; + const seen = new Set(); + let visitedProperties = 0; + while (pending.length) { + const current = pending.pop()!; + if (typeof current.value === "string" && current.value.length > 4096) return false; + if (typeof current.value === "function" || typeof current.value === "symbol") return false; + if (!current.value || typeof current.value !== "object") continue; + if (seen.has(current.value)) continue; + if (current.depth > 8 || seen.size >= 256) return false; + seen.add(current.value); + const names = Object.getOwnPropertyNames(current.value); + if (names.length > 64 || Object.getOwnPropertySymbols(current.value).length > 0) return false; + if (Array.isArray(current.value)) { + const length = Object.getOwnPropertyDescriptor(current.value, "length"); + if (!length || !Object.prototype.hasOwnProperty.call(length, "value") || + !Number.isSafeInteger(length.value) || length.value < 0 || length.value > 64) return false; + } + if (current.depth > 0) { + const prototype = Object.getPrototypeOf(current.value); + if (prototype !== Object.prototype && prototype !== null && prototype !== Array.prototype) return false; + } + for (const name of names) { + if (++visitedProperties > 2048) return false; + const descriptor = Object.getOwnPropertyDescriptor(current.value, name); + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, "value")) return false; + if (descriptor.enumerable) pending.push({ value: descriptor.value, depth: current.depth + 1 }); + } + } + if (typeof structuredClone !== "function") return false; + structuredClone(value); + return true; + } catch { return false; } +} + +export function readBoundedOwnDataArray(value: unknown, maxLength: number): unknown[] | undefined { + try { + if (!Array.isArray(value)) return undefined; + const lengthProperty = readOwnDataProperty(value, "length"); + if (!lengthProperty?.exists || !Number.isSafeInteger(lengthProperty.value) || + (lengthProperty.value as number) < 0 || (lengthProperty.value as number) > maxLength) return undefined; + const length = lengthProperty.value as number; + const items = new Array(length); + for (let index = 0; index < length; index++) { + const property = readOwnDataProperty(value, String(index)); + if (!property?.exists) return undefined; + items[index] = property.value; + } + return items; + } catch { return undefined; } +} + +export function readExactBoundedOwnDataArray(value: unknown, maxLength: number): unknown[] | undefined { + try { + const items = readBoundedOwnDataArray(value, maxLength); + if (!items) return undefined; + const names = Object.getOwnPropertyNames(value); + const symbols = Object.getOwnPropertySymbols(value); + return names.length === items.length + 1 && symbols.length === 0 ? items : undefined; + } catch { return undefined; } +} + +export function isStructuredCloneSafePlainData( + value: unknown, + maxNodes = 3000, + maxDepth = 32, + maxArrayLength = 1000 +): boolean { + if (!value || typeof value !== "object") return false; + const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; + const seen = new Set(); + let visitedNodes = 0; + let visitedProperties = 0; + while (pending.length) { + const current = pending.pop()!; + if (typeof current.value === "function" || typeof current.value === "symbol") return false; + if (!current.value || typeof current.value !== "object") continue; + if (current.depth > maxDepth || ++visitedNodes > maxNodes) return false; + if (seen.has(current.value)) continue; + seen.add(current.value); + let keys: string[]; + let symbols: symbol[]; + let isArray: boolean; + try { + keys = Object.keys(current.value); + symbols = Object.getOwnPropertySymbols(current.value); + isArray = Array.isArray(current.value); + if (!isArray) { + const prototype = Object.getPrototypeOf(current.value); + if (prototype !== Object.prototype && prototype !== null) return false; + } + } catch { return false; } + if (symbols.length > 0) return false; + let arrayLength = -1; + if (isArray) { + const lengthProperty = readOwnDataProperty(current.value, "length"); + if (!lengthProperty?.exists || !Number.isSafeInteger(lengthProperty.value) || + (lengthProperty.value as number) < 0 || + (lengthProperty.value as number) > maxArrayLength) return false; + arrayLength = lengthProperty.value as number; + for (let index = 0; index < arrayLength; index++) { + if (++visitedProperties > maxNodes * 8) return false; + const property = readOwnDataProperty(current.value, String(index)); + if (!property?.exists) return false; + pending.push({ value: property.value, depth: current.depth + 1 }); + } + } + for (const key of keys) { + if (isArray) { + const numericIndex = Number(key); + if (Number.isSafeInteger(numericIndex) && numericIndex >= 0 && numericIndex < arrayLength && + String(numericIndex) === key) continue; + } + if (++visitedProperties > maxNodes * 8) return false; + const property = readOwnDataProperty(current.value, key); + if (!property?.exists) return false; + pending.push({ value: property.value, depth: current.depth + 1 }); + } + } + try { + if (typeof structuredClone !== "function") return false; + structuredClone(value); + return true; + } catch { return false; } +} + +export function normalizeReasoningEffortOrder(value: unknown): string[] | undefined { + const items = readBoundedOwnDataArray(value, 64); + if (!items || items.length === 0) return undefined; + const efforts: string[] = []; + const seen = new Set(); + let ultraCount = 0; + for (const item of items) { + let effort: unknown; + if (typeof item === "string") effort = item; + else if (item && typeof item === "object" && !Array.isArray(item)) { + const property = readOwnDataProperty(item, "reasoningEffort"); + if (!property?.exists || !isStructuredCloneSafePlainData(item, 64, 4, 64)) return undefined; + effort = property.value; + } + if (!isSafeReasoningIdentifier(effort) || seen.has(effort)) return undefined; + if (effort === "ultra" && ++ultraCount > 1) return undefined; + seen.add(effort); + efforts.push(effort); + } + return isStructuredCloneSafePlainData(value, 256, 8, 64) ? efforts : undefined; +} + +export function normalizeReasoningModelLabel( + value: unknown, + allowLeadingGpt = false +): string | undefined { + if (typeof value !== "string" || value.length === 0 || value.length > 128) return undefined; + const trimmed = value.trim(); + if (!trimmed || !/^[A-Za-z0-9.]+(?:[ -]+[A-Za-z0-9.]+)*$/.test(trimmed)) return undefined; + const tokens = trimmed.split(/[ -]+/).map((token) => token.replace(/[A-Z]/g, (character) => + String.fromCharCode(character.charCodeAt(0) + 32) + )); + if (allowLeadingGpt && tokens[0] === "gpt") tokens.shift(); + return tokens.length > 0 ? tokens.join("-") : undefined; +} + +export function isExplicitlyHiddenReasoningElement(element: ReasoningTriggerElement): boolean { + try { + if (typeof element.getAttribute !== "function") return true; + const ariaHidden = element.getAttribute("aria-hidden"); + const hiddenAttribute = element.getAttribute("hidden"); + const className = element.getAttribute("class"); + if (ariaHidden === "true" || hiddenAttribute !== null || + (typeof className === "string" && /ModelPickerTriggerMeasurement/i.test(className))) return true; + if (typeof getComputedStyle !== "function") return false; + const style = getComputedStyle(element as unknown as Element); + return style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse"; + } catch { return true; } +} + +export function readVisibleReasoningModelLabels( + trigger: ReasoningTriggerElement, + isVisible = isVisibleReasoningTrigger, + isExplicitlyHidden = isExplicitlyHiddenReasoningElement +): string[] | undefined { + try { + const descendants = trigger.querySelectorAll?.("*"); + if (!descendants || !Number.isSafeInteger(descendants.length) || descendants.length > 256) return undefined; + const candidates: string[] = []; + for (let index = 0; index < descendants.length; index++) { + const descendant = descendants[index] as Record | undefined; + if (!descendant || typeof descendant !== "object") return undefined; + const children = descendant.children; + if (!children || !Number.isSafeInteger(children.length) || children.length < 0) return undefined; + if (children.length !== 0) continue; + if (!isVisible(descendant as ReasoningTriggerElement)) continue; + let current: Record | undefined = descendant; + let reachedTrigger = false; + let hidden = false; + for (let depth = 0; current && depth <= 32; depth++) { + if (current === trigger) { + reachedTrigger = true; + break; + } + if (isExplicitlyHidden(current as ReasoningTriggerElement)) { + hidden = true; + break; + } + current = current.parentElement; + } + if (hidden) continue; + if (!reachedTrigger) return undefined; + const text = descendant.textContent; + if (typeof text !== "string") return undefined; + const label = text.trim(); + if (!label) continue; + if (!normalizeReasoningModelLabel(label)) return undefined; + candidates.push(label); + } + return candidates; + } catch { return undefined; } +} + +export function findRendererQueryClients(rootFiber: unknown): unknown[] { + try { + const queue = [rootFiber]; + const seen = new Set(); + const seenContexts = new Set(); + const candidateClients = new Set(); + let contextTraversalTruncated = false; + while (queue.length && seen.size < 30000) { + const value = queue.pop(); + if (!value || typeof value !== "object" || seen.has(value)) continue; + seen.add(value); + const memoizedPropsProperty = readOwnDataProperty(value, "memoizedProps"); + const dependenciesProperty = readOwnDataProperty(value, "dependencies"); + const childProperty = readOwnDataProperty(value, "child"); + const siblingProperty = readOwnDataProperty(value, "sibling"); + if (!memoizedPropsProperty || !dependenciesProperty || !childProperty || !siblingProperty) return []; + const contextValues: unknown[] = []; + if (memoizedPropsProperty.exists && memoizedPropsProperty.value && + typeof memoizedPropsProperty.value === "object") { + const valueProperty = readOwnDataProperty(memoizedPropsProperty.value, "value"); + if (!valueProperty) return []; + if (valueProperty.exists) contextValues.push(valueProperty.value); + } + let dependency: unknown; + if (dependenciesProperty.exists && dependenciesProperty.value && + typeof dependenciesProperty.value === "object") { + const firstContextProperty = readOwnDataProperty(dependenciesProperty.value, "firstContext"); + if (!firstContextProperty) return []; + dependency = firstContextProperty.value; + } + while (dependency && typeof dependency === "object" && seenContexts.size < 30000 && + !seenContexts.has(dependency)) { + seenContexts.add(dependency); + const memoizedValueProperty = readOwnDataProperty(dependency, "memoizedValue"); + const nextProperty = readOwnDataProperty(dependency, "next"); + if (!memoizedValueProperty || !nextProperty) return []; + if (memoizedValueProperty.exists) contextValues.push(memoizedValueProperty.value); + dependency = nextProperty.value; + } + if (dependency && typeof dependency === "object" && !seenContexts.has(dependency)) { + contextTraversalTruncated = true; + } + for (const contextValue of contextValues) { + const getQueriesData = readDataPropertyInPrototypeChain(contextValue, "getQueriesData"); + const getQueryData = readDataPropertyInPrototypeChain(contextValue, "getQueryData"); + if (getQueriesData?.exists && typeof getQueriesData.value === "function" && + getQueryData?.exists && typeof getQueryData.value === "function") { + candidateClients.add(contextValue as object); + } + } + queue.push(childProperty.value, siblingProperty.value); + } + const fiberTraversalTruncated = queue.some((value) => + value && typeof value === "object" && !seen.has(value) + ); + if (contextTraversalTruncated || fiberTraversalTruncated) return []; + const queryClients: object[] = []; + for (const candidate of candidateClients) { + if (!isCloneableReasoningQueryClient(candidate)) return []; + queryClients.push(candidate); + } + return queryClients; + } catch { return []; } +} + +export function readReasoningModelCatalogMatch( + queryClients: Iterable, + visibleLabels: unknown +): { modelId: string; supportedEfforts: string[] } | undefined { + const catalog = readReasoningModelCatalog(queryClients); + const match = catalog && matchActiveReasoningModel(visibleLabels, catalog); + return match ? { modelId: match.modelId, supportedEfforts: match.supportedReasoningEfforts } : undefined; +} + +export function readReasoningModelCatalog( + queryClients: Iterable +): CodexModelCatalogEntry[] | undefined { + const recordsByModel = new Map(); + let visitedClients = 0; + try { + for (const candidate of queryClients) { + if (++visitedClients > 64) return undefined; + if (!isCloneableReasoningQueryClient(candidate)) return undefined; + const getQueriesDataProperty = readDataPropertyInPrototypeChain(candidate, "getQueriesData"); + if (!getQueriesDataProperty?.exists || typeof getQueriesDataProperty.value !== "function") return undefined; + const rawEntries = getQueriesDataProperty.value.call(candidate, { queryKey: ["models", "list"] }); + const rawEntryItems = readBoundedOwnDataArray(rawEntries, 64); + if (!rawEntryItems || !isStructuredCloneSafePlainData(rawEntries, 70000, 32, 1000)) return undefined; + for (const rawEntry of rawEntryItems) { + const rawEntryParts = readBoundedOwnDataArray(rawEntry, 2); + if (!rawEntryParts || rawEntryParts.length !== 2 || + !readExactBoundedOwnDataArray(rawEntryParts[0], 64)) return undefined; + } + const entries = readBoundedOwnDataArray(structuredClone(rawEntries), 64); + if (!entries) return undefined; + for (const entry of entries) { + const entryItems = readBoundedOwnDataArray(entry, 2); + if (!entryItems || entryItems.length !== 2) return undefined; + const queryKey = readExactBoundedOwnDataArray(entryItems[0], 64); + if (!queryKey || !isStructuredCloneSafePlainData(entryItems[0], 128, 8, 64)) return undefined; + const hasCatalogPrefix = queryKey[0] === "models" && queryKey[1] === "list"; + const isLegacyCatalogKey = queryKey.length === 2; + const isVersionedCatalogKey = queryKey.length === 5 && + isSafeReasoningIdentifier(queryKey[2]) && + isSafeReasoningIdentifier(queryKey[3]) && + Number.isSafeInteger(queryKey[4]) && (queryKey[4] as number) >= 0; + if (!hasCatalogPrefix || (!isLegacyCatalogKey && !isVersionedCatalogKey)) continue; + const queryData = entryItems[1]; + if (queryData === undefined) continue; + if (!queryData || typeof queryData !== "object") return undefined; + const recordsProperty = readOwnDataProperty(queryData, "data"); + if (!recordsProperty?.exists) return undefined; + const records = readBoundedOwnDataArray(recordsProperty.value, 32); + if (!records || records.length === 0) return undefined; + for (const record of records) { + if (!record || typeof record !== "object" || Array.isArray(record)) return undefined; + const displayNameProperty = readOwnDataProperty(record, "displayName"); + const modelProperty = readOwnDataProperty(record, "model"); + if (!displayNameProperty?.exists || !modelProperty?.exists || + !isSafeReasoningIdentifier(modelProperty.value, 128) || + typeof displayNameProperty.value !== "string" || displayNameProperty.value.length > 80) return undefined; + const normalizedDisplayName = normalizeReasoningModelLabel(displayNameProperty.value, true); + if (!normalizedDisplayName) return undefined; + const displayName = displayNameProperty.value.trim() + .replace(/^gpt[ -]+/i, "") + .replace(/[ -]+/g, " "); + if (!displayName || displayName.length > 80) return undefined; + const effortsProperty = readOwnDataProperty(record, "supportedReasoningEfforts"); + if (!effortsProperty?.exists) return undefined; + const effortItems = readBoundedOwnDataArray(effortsProperty.value, 16); + if (!effortItems || effortItems.length === 0 || effortItems.some((effortRecord) => + !effortRecord || typeof effortRecord !== "object" || Array.isArray(effortRecord) + )) return undefined; + const efforts = normalizeReasoningEffortOrder(effortsProperty.value); + if (!efforts) return undefined; + if (!isStructuredCloneSafePlainData(record, 5000, 32, 1000)) return undefined; + const modelId = modelProperty.value; + const existing = recordsByModel.get(modelId); + if (existing) { + if (existing.normalizedDisplayName !== normalizedDisplayName || + JSON.stringify(existing.entry.supportedReasoningEfforts) !== JSON.stringify(efforts)) return undefined; + } else { + if (recordsByModel.size >= 32) return undefined; + recordsByModel.set(modelId, { + entry: { modelId, displayName, supportedReasoningEfforts: efforts }, + normalizedDisplayName + }); + } + } + if (!isStructuredCloneSafePlainData(recordsProperty.value, 70000, 32, 1000) || + !isStructuredCloneSafePlainData(queryData, 70001, 32, 1000)) return undefined; + } + } + } catch { return undefined; } + return recordsByModel.size > 0 ? [...recordsByModel.values()].map(({ entry }) => entry) : undefined; +} + +export function matchActiveReasoningModel( + visibleLabels: unknown, + catalog: unknown +): CodexModelCatalogEntry | undefined { + const labelItems = readBoundedOwnDataArray(visibleLabels, 256); + if (!labelItems) return undefined; + const normalizedLabels = new Set(); + for (const label of labelItems) { + const normalizedLabel = normalizeReasoningModelLabel(label); + if (!normalizedLabel) return undefined; + normalizedLabels.add(normalizedLabel); + } + const catalogItems = readBoundedOwnDataArray(catalog, 32); + if (!catalogItems || catalogItems.length === 0) return undefined; + const matches = new Map(); + try { + for (const entry of catalogItems) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined; + const displayNameProperty = readOwnDataProperty(entry, "displayName"); + const modelIdProperty = readOwnDataProperty(entry, "modelId"); + const effortsProperty = readOwnDataProperty(entry, "supportedReasoningEfforts"); + if (!displayNameProperty?.exists || !modelIdProperty?.exists || !effortsProperty?.exists || + !isSafeReasoningIdentifier(modelIdProperty.value, 128)) return undefined; + const normalizedDisplayName = normalizeReasoningModelLabel(displayNameProperty.value); + if (!normalizedDisplayName) return undefined; + const efforts = normalizeReasoningEffortOrder(effortsProperty.value); + if (!efforts || efforts.length > 16) return undefined; + if (normalizedLabels.has(normalizedDisplayName)) { + const match = entry as CodexModelCatalogEntry; + matches.set(JSON.stringify([match.modelId, normalizedDisplayName, efforts]), match); + } + } + } catch { return undefined; } + return matches.size === 1 ? matches.values().next().value : undefined; +} + +export function readActiveReasoningMetadata( + elements: Iterable, + reactRootFiber: unknown, + isVisible = isVisibleReasoningTrigger, + isExplicitlyHidden = isExplicitlyHiddenReasoningElement +): { currentEffort: string; modelId: string; modelDisplayName: string; supportedEfforts: string[]; + modelCatalog: CodexModelCatalogEntry[] } | undefined { + try { + const visibleTriggers: ReasoningTriggerElement[] = []; + for (const element of elements) { + if (element.getAttribute("data-codex-intelligence-trigger") !== "true" || + element.getAttribute("data-composer-navigation-target") !== "reasoning" || !isVisible(element) || + isExplicitlyHidden(element)) continue; + visibleTriggers.push(element); + } + if (visibleTriggers.length !== 1) return undefined; + const trigger = visibleTriggers[0]!; + const currentEffort = trigger.getAttribute("data-selected-reasoning-effort")?.trim(); + if (!isSafeReasoningIdentifier(currentEffort)) return undefined; + const visibleLabels = readVisibleReasoningModelLabels(trigger, isVisible, isExplicitlyHidden); + if (!visibleLabels) return undefined; + const modelCatalog = readReasoningModelCatalog(findRendererQueryClients(reactRootFiber)); + const match = modelCatalog && matchActiveReasoningModel(visibleLabels, modelCatalog); + return match && match.supportedReasoningEfforts.includes(currentEffort) ? { + currentEffort, + modelId: match.modelId, + modelDisplayName: match.displayName, + supportedEfforts: match.supportedReasoningEfforts, + modelCatalog + } : undefined; + } catch { return undefined; } +} + +type ModelPresetSelector = { + modelId: string; + reasoningEffort: string; + modelCatalog: CodexModelCatalogEntry[]; + select: (modelId: string, reasoningEffort: string) => unknown; }; -const SNAPSHOT_EXPRESSION = `(async () => { +export function readComponentModelCatalog(value: unknown): CodexModelCatalogEntry[] | undefined { + try { + const records = readExactBoundedOwnDataArray(value, 32); + if (!records || records.length === 0 || Object.getOwnPropertySymbols(value).length > 0) return undefined; + const catalog: CodexModelCatalogEntry[] = []; + const seen = new Set(); + for (const record of records) { + if (!record || typeof record !== "object" || Array.isArray(record) || + Object.getOwnPropertySymbols(record).length > 0) return undefined; + const model = readOwnDataProperty(record, "model"); + const displayName = readOwnDataProperty(record, "displayName"); + const effortRecords = readOwnDataProperty(record, "supportedReasoningEfforts"); + if (!model?.exists || !displayName?.exists || !effortRecords?.exists || + !isSafeReasoningIdentifier(model.value, 128) || seen.has(model.value) || + typeof displayName.value !== "string" || displayName.value.length === 0 || + displayName.value.length > 80) return undefined; + const normalizedName = normalizeReasoningModelLabel(displayName.value, true); + const exactEffortRecords = readExactBoundedOwnDataArray(effortRecords.value, 16); + const efforts = normalizeReasoningEffortOrder(effortRecords.value); + if (!normalizedName || !exactEffortRecords || exactEffortRecords.length === 0 || !efforts || efforts.length > 16) { + return undefined; + } + const normalizedDisplayName = displayName.value.trim().replace(/^gpt[ -]+/i, "").replace(/[ -]+/g, " "); + if (!normalizedDisplayName || normalizedDisplayName.length > 80) return undefined; + seen.add(model.value); + catalog.push({ modelId: model.value, displayName: normalizedDisplayName, supportedReasoningEfforts: efforts }); + } + return catalog; + } catch { return undefined; } +} + +export function readNormalizedModelCatalog(value: unknown): CodexModelCatalogEntry[] | undefined { + try { + const rawEntries = readExactBoundedOwnDataArray(value, 32); + if (!rawEntries || rawEntries.length === 0 || Object.getOwnPropertySymbols(value).length > 0) return undefined; + const entries: CodexModelCatalogEntry[] = []; + const modelIds = new Set(); + const displayNames = new Set(); + for (const rawEntry of rawEntries) { + if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry) || + Object.getOwnPropertySymbols(rawEntry).length > 0) return undefined; + const prototype = Object.getPrototypeOf(rawEntry); + const names = Object.getOwnPropertyNames(rawEntry); + if ((prototype !== Object.prototype && prototype !== null) || names.length !== 3 || + !names.includes("modelId") || !names.includes("displayName") || + !names.includes("supportedReasoningEfforts")) return undefined; + const modelId = readOwnDataProperty(rawEntry, "modelId"); + const displayName = readOwnDataProperty(rawEntry, "displayName"); + const rawEfforts = readOwnDataProperty(rawEntry, "supportedReasoningEfforts"); + if (!modelId?.exists || !isSafeReasoningIdentifier(modelId.value, 128) || modelIds.has(modelId.value) || + !displayName?.exists || typeof displayName.value !== "string" || displayName.value.length === 0 || + displayName.value.length > 80 || !rawEfforts?.exists) return undefined; + const normalizedDisplayName = normalizeReasoningModelLabel(displayName.value); + const efforts = readExactBoundedOwnDataArray(rawEfforts.value, 16); + if (!normalizedDisplayName || displayNames.has(normalizedDisplayName) || !efforts || efforts.length === 0 || + Object.getOwnPropertySymbols(rawEfforts.value).length > 0) return undefined; + const normalizedEfforts: string[] = []; + const seenEfforts = new Set(); + for (const effort of efforts) { + if (!isSafeReasoningIdentifier(effort) || seenEfforts.has(effort)) return undefined; + seenEfforts.add(effort); + normalizedEfforts.push(effort); + } + modelIds.add(modelId.value); + displayNames.add(normalizedDisplayName); + entries.push({ + modelId: modelId.value, + displayName: normalizedDisplayName, + supportedReasoningEfforts: normalizedEfforts + }); + } + return entries; + } catch { return undefined; } +} + +export function isAuthorizedModelCatalogProjection( + componentValue: unknown, + authoritativeValue: unknown +): boolean { + const component = readNormalizedModelCatalog(componentValue); + const authoritative = readNormalizedModelCatalog(authoritativeValue); + if (!component || !authoritative) return false; + const authoritativeById = new Map(authoritative.map((entry) => [entry.modelId, entry])); + for (const componentEntry of component) { + const authoritativeEntry = authoritativeById.get(componentEntry.modelId); + if (!authoritativeEntry || componentEntry.displayName !== authoritativeEntry.displayName) return false; + let authoritativeIndex = 0; + for (const effort of componentEntry.supportedReasoningEfforts) { + while (authoritativeIndex < authoritativeEntry.supportedReasoningEfforts.length && + authoritativeEntry.supportedReasoningEfforts[authoritativeIndex] !== effort) authoritativeIndex++; + if (authoritativeIndex >= authoritativeEntry.supportedReasoningEfforts.length) return false; + authoritativeIndex++; + } + } + return true; +} + +export function readModelPresetSelector( + elements: Iterable, + authoritativeCatalog: unknown, + isVisible = isVisibleReasoningTrigger, + isExplicitlyHidden = isExplicitlyHiddenReasoningElement +): ModelPresetSelector | undefined { + try { + const visibleTriggers: ReasoningTriggerElement[] = []; + for (const element of elements) { + if (element.getAttribute("data-codex-intelligence-trigger") !== "true" || + element.getAttribute("data-composer-navigation-target") !== "reasoning" || + !isVisible(element) || isExplicitlyHidden(element)) continue; + visibleTriggers.push(element); + } + if (visibleTriggers.length !== 1) return undefined; + const trigger = visibleTriggers[0] as unknown as object; + const reactKeys = Object.getOwnPropertyNames(trigger).filter((key) => key.startsWith("__reactFiber$")); + if (reactKeys.length !== 1 || Object.getOwnPropertySymbols(trigger).length > 0) return undefined; + const fiberProperty = readOwnDataProperty(trigger, reactKeys[0]!); + if (!fiberProperty?.exists || !fiberProperty.value || typeof fiberProperty.value !== "object") return undefined; + const authoritative = readBoundedOwnDataArray(authoritativeCatalog, 32); + if (!authoritative || authoritative.length === 0) return undefined; + const candidates: ModelPresetSelector[] = []; + let fiber: unknown = fiberProperty.value; + const seen = new Set(); + for (let depth = 0; fiber && typeof fiber === "object" && depth < 256; depth++) { + if (seen.has(fiber as object)) return undefined; + if (Object.getOwnPropertySymbols(fiber).length > 0) return undefined; + seen.add(fiber as object); + const propsProperty = readOwnDataProperty(fiber, "memoizedProps"); + if (!propsProperty) return undefined; + const props = propsProperty.value; + if (propsProperty.exists && props && typeof props === "object" && !Array.isArray(props)) { + if (Object.getOwnPropertySymbols(props).length > 0) return undefined; + const model = readOwnDataProperty(props, "model"); + const effort = readOwnDataProperty(props, "reasoningEffort"); + const models = readOwnDataProperty(props, "models"); + const selectModel = readOwnDataProperty(props, "onSelectModel"); + const selectEffort = readOwnDataProperty(props, "onSelectReasoningEffort"); + const hasCoreProperty = model?.exists || effort?.exists || models?.exists || + selectModel?.exists || selectEffort?.exists; + const selectModelLength = selectModel?.exists && typeof selectModel.value === "function" + ? readOwnDataProperty(selectModel.value, "length") : undefined; + const selectEffortLength = selectEffort?.exists && typeof selectEffort.value === "function" + ? readOwnDataProperty(selectEffort.value, "length") : undefined; + if (hasCoreProperty && (!model?.exists || !effort?.exists || !models?.exists || + !selectModel?.exists || !selectEffort?.exists || + !isSafeReasoningIdentifier(model.value, 128) || !isSafeReasoningIdentifier(effort.value) || + typeof selectModel.value !== "function" || !selectModelLength?.exists || selectModelLength.value !== 2 || + typeof selectEffort.value !== "function" || !selectEffortLength?.exists || selectEffortLength.value !== 1)) { + return undefined; + } + if (hasCoreProperty) { + const currentModel = model!.value as string; + const currentEffort = effort!.value as string; + const modelSelector = selectModel!.value as ModelPresetSelector["select"]; + const effortSelector = selectEffort!.value as (reasoningEffort: string) => unknown; + const componentCatalog = readComponentModelCatalog(models!.value); + let modelSource: string; + let effortSource: string; + try { + modelSource = Function.prototype.toString.call(modelSelector).replace(/\s+/g, ""); + effortSource = Function.prototype.toString.call(effortSelector).replace(/\s+/g, ""); + } catch { return undefined; } + const modelMatch = /^\(([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\)=>\{([A-Za-z_$][\w$]*)\(\1,\2\)\}$/.exec(modelSource); + const effortMatch = /^([A-Za-z_$][\w$]*)=>\{([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*),\1\)\}$/.exec(effortSource); + const active = componentCatalog?.find((entry) => entry.modelId === currentModel); + if (!componentCatalog || !isAuthorizedModelCatalogProjection(componentCatalog, authoritative) || !active || + !active.supportedReasoningEfforts.includes(currentEffort) || !modelMatch || !effortMatch || + modelMatch[3] !== effortMatch[2] || effortMatch[3] === effortMatch[1]) return undefined; + candidates.push({ + modelId: currentModel, + reasoningEffort: currentEffort, + modelCatalog: componentCatalog, + select: modelSelector + }); + } + } + const returnProperty = readOwnDataProperty(fiber, "return"); + if (!returnProperty) return undefined; + fiber = returnProperty.value; + } + if (fiber != null) return undefined; + return candidates.length === 1 ? candidates[0] : undefined; + } catch { return undefined; } +} + +function normalizeModelPresetRequest(value: unknown): ModelPresetRequest | undefined { + try { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getOwnPropertySymbols(value).length > 0) { + return undefined; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const names = Object.getOwnPropertyNames(value); + if (names.length !== 3 || !names.includes("modelId") || !names.includes("reasoningEffort") || + !names.includes("includeUltra")) return undefined; + const modelId = readOwnDataProperty(value, "modelId"); + const reasoningEffort = readOwnDataProperty(value, "reasoningEffort"); + const includeUltra = readOwnDataProperty(value, "includeUltra"); + if (!modelId?.exists || !reasoningEffort?.exists || !includeUltra?.exists || + !isSafeReasoningIdentifier(modelId.value, 128) || !isSafeReasoningIdentifier(reasoningEffort.value) || + typeof includeUltra.value !== "boolean") return undefined; + return { modelId: modelId.value, reasoningEffort: reasoningEffort.value, includeUltra: includeUltra.value }; + } catch { return undefined; } +} + +export function decideReasoningAdjustment( + direction: ReasoningAdjustment, + policy: ReasoningAdjustmentPolicy, + currentEffort?: string, + supportedEfforts?: unknown +): ReasoningAdjustmentResult | "unavailable" { + if (direction === "decrease" || policy.includeUltra) return "applied"; + if (!isSafeReasoningIdentifier(currentEffort)) return "unavailable"; + const effortOrder = normalizeReasoningEffortOrder(supportedEfforts); + if (!effortOrder) return "unavailable"; + const currentIndex = effortOrder.indexOf(currentEffort); + if (currentIndex < 0) return "unavailable"; + return effortOrder[currentIndex + 1] === "ultra" ? "blocked-ultra" : "applied"; +} + +export function hasFastModeIndicator(element: ReasoningTriggerElement): boolean { + return Boolean(element.querySelector?.('svg[class*="ModelPickerTriggerInlineFastIcon"]')); +} + +export function readActiveFastMode( + elements: Iterable, + isVisible = isVisibleReasoningTrigger, + hasFastIndicator = hasFastModeIndicator +): boolean | undefined { + const candidates = new Set(); + for (const element of elements) { + if (!isVisible(element)) continue; + if (element.getAttribute("data-composer-navigation-target") !== "reasoning") continue; + candidates.add(hasFastIndicator(element)); + } + return candidates.size === 1 ? candidates.values().next().value : undefined; +} + +export function hasApplicableResetCredit(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +const SNAPSHOT_EXPRESSION = (forceUsageRefresh: boolean): string => `(async () => { + const forceUsageRefresh = ${forceUsageRefresh}; + const readUsageQueryData = (${readUsageQueryData.toString()}); + const normalizeRendererUsage = (${normalizeRendererUsage.toString()}); + const isVisibleReasoningTrigger = (${isVisibleReasoningTrigger.toString()}); + const isSafeReasoningIdentifier = (${isSafeReasoningIdentifier.toString()}); + const readOwnDataProperty = (${readOwnDataProperty.toString()}); + const readDataPropertyInPrototypeChain = (${readDataPropertyInPrototypeChain.toString()}); + const isCloneableReasoningQueryClient = (${isCloneableReasoningQueryClient.toString()}); + const readBoundedOwnDataArray = (${readBoundedOwnDataArray.toString()}); + const readExactBoundedOwnDataArray = (${readExactBoundedOwnDataArray.toString()}); + const isStructuredCloneSafePlainData = (${isStructuredCloneSafePlainData.toString()}); + const normalizeReasoningEffortOrder = (${normalizeReasoningEffortOrder.toString()}); + const normalizeReasoningModelLabel = (${normalizeReasoningModelLabel.toString()}); + const isExplicitlyHiddenReasoningElement = (${isExplicitlyHiddenReasoningElement.toString()}); + const readVisibleReasoningModelLabels = (${readVisibleReasoningModelLabels.toString()}); + const findRendererQueryClients = (${findRendererQueryClients.toString()}); + const readReasoningModelCatalog = (${readReasoningModelCatalog.toString()}); + const matchActiveReasoningModel = (${matchActiveReasoningModel.toString()}); + const readActiveReasoningMetadata = (${readActiveReasoningMetadata.toString()}); + const readActiveReasoningEffort = (${readActiveReasoningEffort.toString()}); + const hasFastModeIndicator = (${hasFastModeIndicator.toString()}); + const readActiveFastMode = (${readActiveFastMode.toString()}); const urls = [...new Set([ ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), ...performance.getEntriesByType('resource').map((entry) => entry.name) @@ -214,61 +1177,23 @@ const SNAPSHOT_EXPRESSION = `(async () => { })); let usage; + let forcedUsageQueryFound = false; for (const client of queryClients) { try { const query = client.getQueryCache().getAll().find((candidate) => JSON.stringify(candidate.queryKey) === '["rate-limit-status"]' ); - const refreshKey = Symbol.for('codex-deck-rate-limit-refresh-at'); - const now = Date.now(); - const dataUpdatedAt = Number(query?.state?.dataUpdatedAt) || 0; - const lastRefreshAttempt = Number(globalThis[refreshKey]) || 0; - if (query && typeof query.fetch === 'function' && now - dataUpdatedAt >= 15000 && now - lastRefreshAttempt >= 15000) { - globalThis[refreshKey] = now; - // Rate-limit refresh is network-backed and must never hold agent status, - // selection, or lighting behind its response. A later snapshot reads - // the refreshed query cache once this best-effort request completes. - try { Promise.resolve(query.fetch()).catch(() => {}); } catch {} - } - const data = query?.state?.data; - const rateLimit = data?.rate_limit; - if (!rateLimit || typeof rateLimit !== 'object') continue; - const normalizeWindow = (window, role) => { - if (!window || typeof window !== 'object') return null; - const used = Number(window.used_percent); - if (!Number.isFinite(used)) return null; - const seconds = Number(window.limit_window_seconds); - const minutes = Number.isFinite(seconds) && seconds > 0 ? seconds / 60 : null; - const kind = minutes != null && Math.abs(minutes - 300) <= 1 ? 'five-hour' - : minutes != null && Math.abs(minutes - 10080) <= 1 ? 'weekly' - : 'other'; - const usedPercent = Math.min(100, Math.max(0, used)); - return { - id: kind === 'other' ? role + '-' + String(minutes ?? 'unknown') : kind, - kind, - usedPercent, - remainingPercent: 100 - usedPercent, - windowDurationMins: minutes, - resetsAt: toEpoch(window.reset_at) ?? null - }; - }; - const windows = [ - normalizeWindow(rateLimit.primary_window, 'primary'), - normalizeWindow(rateLimit.secondary_window, 'secondary') - ].filter(Boolean); - const available = Number(data.rate_limit_reset_credits?.available_count); - const applicable = Number(data.rate_limit_reset_credits?.applicable_available_count); - usage = { - windows, - observedAt: Number.isFinite(query.state?.dataUpdatedAt) && query.state.dataUpdatedAt > 0 - ? query.state.dataUpdatedAt - : Date.now(), - resetCreditsAvailable: Number.isFinite(available) ? Math.max(0, Math.floor(available)) : null, - resetCreditsApplicable: Number.isFinite(applicable) ? Math.max(0, Math.floor(applicable)) : null - }; + if (forceUsageRefresh && query && typeof query.fetch === 'function') forcedUsageQueryFound = true; + const data = await readUsageQueryData(query, forceUsageRefresh); + usage = normalizeRendererUsage(data, query?.state?.dataUpdatedAt); + if (!usage) continue; break; - } catch {} + } catch (error) { + if (forceUsageRefresh) throw error; + } } + if (forceUsageRefresh && !forcedUsageQueryFound) throw new Error('Codex usage query is unavailable.'); + if (forceUsageRefresh && !usage) throw new Error('Codex usage refresh returned no valid rate-limit usage.'); const html = document.documentElement; const body = document.body; @@ -303,8 +1228,30 @@ const SNAPSHOT_EXPRESSION = `(async () => { const activeThreadTitle = activeThreadElement ? (activeThreadElement.getAttribute('aria-label') ?? activeThreadElement.textContent ?? '').trim().slice(0, 240) || undefined : undefined; + const reactRootProperty = readOwnDataProperty(root, reactKey); + const reasoningMetadata = reactRootProperty?.exists + ? readActiveReasoningMetadata(document.querySelectorAll( + '[data-codex-intelligence-trigger="true"][data-composer-navigation-target="reasoning"]' + ), reactRootProperty.value) + : undefined; + const fallbackReasoningEffort = reasoningMetadata ? undefined : readActiveReasoningEffort(document.querySelectorAll( + '[data-codex-intelligence-trigger="true"][data-composer-navigation-target="reasoning"]' + )); + const fastModeEnabled = readActiveFastMode(document.querySelectorAll( + '[data-codex-intelligence-trigger="true"][data-composer-navigation-target="reasoning"]' + )); - return { slots, activeThreadKey, activeThreadTitle, layout, agentSource, lightingAutoOff, theme, ...(usage ? { usage } : {}) }; + return { + slots, activeThreadKey, activeThreadTitle, layout, agentSource, lightingAutoOff, theme, + ...(reasoningMetadata ? { + reasoningEffort: reasoningMetadata.currentEffort, + activeModelId: reasoningMetadata.modelId, + activeModelDisplayName: reasoningMetadata.modelDisplayName, + modelCatalog: reasoningMetadata.modelCatalog + } : fallbackReasoningEffort ? { reasoningEffort: fallbackReasoningEffort } : {}), + ...(typeof fastModeEnabled === 'boolean' ? { fastModeEnabled } : {}), + ...(usage ? { usage } : {}) + }; })()`; export class CodexMicroRendererBridge { @@ -314,23 +1261,43 @@ export class CodexMicroRendererBridge { private connecting?: Promise; private lastSnapshot?: MicroSnapshot; private readonly sessionOwnership = new CodexSessionOwnershipIndex(); - private readonly evaluationNamespace = randomUUID(); + private evaluationNamespace = randomUUID(); constructor(private readonly log: (message: string) => void) {} async refresh(): Promise { try { - await this.ensureConnected(); - const nativeSnapshot = await this.evaluate(SNAPSHOT_EXPRESSION); - const snapshot = await this.sessionOwnership.annotate(nativeSnapshot); - this.lastSnapshot = snapshot; - return snapshot; + return await this.readSnapshot(false); + } catch (error) { + this.disconnect(); + throw error; + } + } + + async requestUsageRefresh(): Promise { + try { + return await this.readSnapshot(true); } catch (error) { this.disconnect(); throw error; } } + async refreshUsage(): Promise { + await this.requestUsageRefresh(); + } + + private async readSnapshot(forceUsageRefresh: boolean): Promise { + await this.ensureConnected(); + const nativeSnapshot = await this.evaluate(SNAPSHOT_EXPRESSION(forceUsageRefresh)); + if (forceUsageRefresh && !hasValidNormalizedUsage(nativeSnapshot.usage)) { + throw new Error("Codex usage refresh returned no valid rate-limit usage."); + } + const snapshot = await this.sessionOwnership.annotate(nativeSnapshot); + this.lastSnapshot = snapshot; + return snapshot; + } + async sendAgent(slot: number, act: 0 | 1, expectedThreadKey?: string): Promise { if (!Number.isInteger(slot) || slot < 0 || slot > 5) throw new Error(`Ungültiger Micro-Agent-Slot: ${slot}`); const snapshot = act === 1 ? await this.refresh() : this.lastSnapshot ?? await this.refresh(); @@ -400,10 +1367,289 @@ export class CodexMicroRendererBridge { await this.dispatch("codex-micro-hid-event", { event: { key: "ENC", act, slot: null, threadKey: null } }, "codex-micro-hid-event"); } - async adjustReasoning(direction: ReasoningAdjustment): Promise { - await this.dispatch("codex-micro-hid-event", { - event: { key: REASONING_ENCODER_KEYS[direction], act: 2, slot: null, threadKey: null } - }, "codex-micro-hid-event"); + async adjustReasoning( + direction: ReasoningAdjustment, + policy: ReasoningAdjustmentPolicy = { includeUltra: true } + ): Promise { + await this.ensureConnected(); + const expression = `(async () => { + const guardNamespace = ${JSON.stringify(this.evaluationNamespace)}; + const guardStore = globalThis.__codexDeckReasoningGuardStates ??= new Map(); + let guardState = guardStore.get(guardNamespace); + if (!guardState) { + guardState = { tail: Promise.resolve(), uncertain: null }; + guardStore.set(guardNamespace, guardState); + } + const predecessor = guardState.tail; + let releaseGuard; + guardState.tail = new Promise((resolve) => { releaseGuard = resolve; }); + await predecessor; + try { + const direction = ${JSON.stringify(direction)}; + const policy = ${JSON.stringify(policy)}; + const command = direction === 'increase' + ? 'composer.increaseReasoningEffort' + : 'composer.decreaseReasoningEffort'; + const allowedCommands = new Set(${JSON.stringify(REASONING_COMMANDS)}); + if (!allowedCommands.has(command)) throw new Error('Unsupported reasoning command.'); + const isSafeReasoningIdentifier = (${isSafeReasoningIdentifier.toString()}); + const readOwnDataProperty = (${readOwnDataProperty.toString()}); + const readDataPropertyInPrototypeChain = (${readDataPropertyInPrototypeChain.toString()}); + const isCloneableReasoningQueryClient = (${isCloneableReasoningQueryClient.toString()}); + const readBoundedOwnDataArray = (${readBoundedOwnDataArray.toString()}); + const readExactBoundedOwnDataArray = (${readExactBoundedOwnDataArray.toString()}); + const isStructuredCloneSafePlainData = (${isStructuredCloneSafePlainData.toString()}); + const normalizeReasoningEffortOrder = (${normalizeReasoningEffortOrder.toString()}); + const normalizeReasoningModelLabel = (${normalizeReasoningModelLabel.toString()}); + const isVisibleReasoningTrigger = (${isVisibleReasoningTrigger.toString()}); + const isExplicitlyHiddenReasoningElement = (${isExplicitlyHiddenReasoningElement.toString()}); + const readVisibleReasoningModelLabels = (${readVisibleReasoningModelLabels.toString()}); + const findRendererQueryClients = (${findRendererQueryClients.toString()}); + const readReasoningModelCatalog = (${readReasoningModelCatalog.toString()}); + const matchActiveReasoningModel = (${matchActiveReasoningModel.toString()}); + const readReasoningModelCatalogMatch = (${readReasoningModelCatalogMatch.toString()}); + const readActiveReasoningMetadata = (${readActiveReasoningMetadata.toString()}); + const decideReasoningAdjustment = (${decideReasoningAdjustment.toString()}); + const readConfirmedReasoningEffort = (${readConfirmedReasoningEffort.toString()}); + const reasoningSelector = '[data-codex-intelligence-trigger="true"][data-composer-navigation-target="reasoning"]'; + const readLiveMetadata = () => { + const root = document.getElementById('root'); + const reactKey = root && Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactContainer$')); + const reactRootProperty = root && reactKey ? readOwnDataProperty(root, reactKey) : undefined; + return reactRootProperty?.exists + ? readActiveReasoningMetadata(document.querySelectorAll(reasoningSelector), reactRootProperty.value) + : undefined; + }; + const readConfirmedEffort = () => readConfirmedReasoningEffort( + document.querySelectorAll(reasoningSelector) + ); + const planFromMetadata = (metadata) => { + const decision = decideReasoningAdjustment( + direction, policy, metadata?.currentEffort, metadata?.supportedEfforts + ); + if (decision === 'unavailable' || !metadata) return { kind: 'unavailable' }; + if (decision === 'blocked-ultra') { + return { kind: 'blocked-ultra', currentEffort: metadata.currentEffort }; + } + const currentIndex = metadata.supportedEfforts.indexOf(metadata.currentEffort); + if (currentIndex < 0) return { kind: 'unavailable' }; + const targetIndex = currentIndex + (direction === 'increase' ? 1 : -1); + if (targetIndex < 0 || targetIndex >= metadata.supportedEfforts.length) { + return { kind: 'boundary', currentEffort: metadata.currentEffort }; + } + const expectedEffort = metadata.supportedEfforts[targetIndex]; + return isSafeReasoningIdentifier(expectedEffort) + ? { kind: 'command', expectedEffort } + : { kind: 'unavailable' }; + }; + let metadata = readLiveMetadata(); + if (guardState.uncertain) { + if (guardState.uncertain.targetModelId) { + if (!metadata || metadata.modelId !== guardState.uncertain.targetModelId || + metadata.currentEffort !== guardState.uncertain.targetEffort) return 'metadata-unavailable'; + } else if (!metadata || (metadata.modelId === guardState.uncertain.modelId && + metadata.currentEffort === guardState.uncertain.currentEffort)) return 'metadata-unavailable'; + guardState.uncertain = null; + } + let plan = planFromMetadata(metadata); + if (plan.kind === 'unavailable') return 'metadata-unavailable'; + if (plan.kind === 'blocked-ultra') { + return { outcome: 'blocked-ultra', reasoningEffort: plan.currentEffort }; + } + if (plan.kind === 'boundary') { + return { outcome: 'applied', reasoningEffort: plan.currentEffort }; + } + + const urls = [...new Set([ + ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), + ...performance.getEntriesByType('resource').map((entry) => entry.name) + ])]; + const bridgeUrl = urls.find((value) => value.includes('/assets/codex-micro-bridge-')); + if (!bridgeUrl) throw new Error('Codex Micro bridge module is unavailable.'); + const bridgeSource = await (await fetch(bridgeUrl)).text(); + const resolveCommandRunner = (${resolveCommandRunner.toString()}); + const commandRunner = await resolveCommandRunner(bridgeSource, bridgeUrl, (url) => import(url)); + if (typeof commandRunner !== 'function') throw new Error('Codex command runner is unavailable.'); + metadata = readLiveMetadata(); + plan = planFromMetadata(metadata); + if (plan.kind === 'unavailable') return 'metadata-unavailable'; + if (plan.kind === 'blocked-ultra') { + return { outcome: 'blocked-ultra', reasoningEffort: plan.currentEffort }; + } + if (plan.kind === 'boundary') { + return { outcome: 'applied', reasoningEffort: plan.currentEffort }; + } + const priorMetadata = { modelId: metadata.modelId, currentEffort: metadata.currentEffort }; + guardState.uncertain = priorMetadata; + const handled = commandRunner(command, 'codex_micro_hid'); + if (!handled) { + guardState.uncertain = null; + throw new Error('This Codex command is not active in the current view.'); + } + let confirmedEffort = readConfirmedEffort(); + if (confirmedEffort === plan.expectedEffort) { + guardState.uncertain = null; + return { outcome: 'applied', reasoningEffort: confirmedEffort }; + } + await Promise.resolve(); + confirmedEffort = readConfirmedEffort(); + if (confirmedEffort === plan.expectedEffort) { + guardState.uncertain = null; + return { outcome: 'applied', reasoningEffort: confirmedEffort }; + } + for (let attempt = 0; attempt < 8; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 8)); + const confirmedEffort = readConfirmedEffort(); + if (confirmedEffort === plan.expectedEffort) { + guardState.uncertain = null; + return { outcome: 'applied', reasoningEffort: confirmedEffort }; + } + } + return 'metadata-unavailable'; + } finally { + releaseGuard(); + } + })()`; + let result: ReasoningAdjustmentExecution | "metadata-unavailable"; + try { + result = await this.evaluate(expression); + } catch (error) { + this.disconnect(); + throw error; + } + if (result === "metadata-unavailable") throw new Error("Codex reasoning metadata is unavailable."); + return result; + } + + async applyModelPreset(request: ModelPresetRequest): Promise { + const normalizedRequest = normalizeModelPresetRequest(request); + if (!normalizedRequest) throw new Error("Invalid Codex model preset request."); + await this.ensureConnected(); + const expression = `(async () => { + const guardNamespace = ${JSON.stringify(this.evaluationNamespace)}; + const guardStore = globalThis.__codexDeckReasoningGuardStates ??= new Map(); + let guardState = guardStore.get(guardNamespace); + if (!guardState) { + guardState = { tail: Promise.resolve(), uncertain: null }; + guardStore.set(guardNamespace, guardState); + } + const predecessor = guardState.tail; + let releaseGuard; + guardState.tail = new Promise((resolve) => { releaseGuard = resolve; }); + await predecessor; + try { + const request = ${JSON.stringify(normalizedRequest)}; + const isSafeReasoningIdentifier = (${isSafeReasoningIdentifier.toString()}); + const readOwnDataProperty = (${readOwnDataProperty.toString()}); + const readDataPropertyInPrototypeChain = (${readDataPropertyInPrototypeChain.toString()}); + const isCloneableReasoningQueryClient = (${isCloneableReasoningQueryClient.toString()}); + const readBoundedOwnDataArray = (${readBoundedOwnDataArray.toString()}); + const readExactBoundedOwnDataArray = (${readExactBoundedOwnDataArray.toString()}); + const isStructuredCloneSafePlainData = (${isStructuredCloneSafePlainData.toString()}); + const normalizeReasoningEffortOrder = (${normalizeReasoningEffortOrder.toString()}); + const normalizeReasoningModelLabel = (${normalizeReasoningModelLabel.toString()}); + const isVisibleReasoningTrigger = (${isVisibleReasoningTrigger.toString()}); + const isExplicitlyHiddenReasoningElement = (${isExplicitlyHiddenReasoningElement.toString()}); + const readVisibleReasoningModelLabels = (${readVisibleReasoningModelLabels.toString()}); + const findRendererQueryClients = (${findRendererQueryClients.toString()}); + const readReasoningModelCatalog = (${readReasoningModelCatalog.toString()}); + const matchActiveReasoningModel = (${matchActiveReasoningModel.toString()}); + const readActiveReasoningMetadata = (${readActiveReasoningMetadata.toString()}); + const readComponentModelCatalog = (${readComponentModelCatalog.toString()}); + const readNormalizedModelCatalog = (${readNormalizedModelCatalog.toString()}); + const isAuthorizedModelCatalogProjection = (${isAuthorizedModelCatalogProjection.toString()}); + const readModelPresetSelector = (${readModelPresetSelector.toString()}); + const reasoningSelector = '[data-codex-intelligence-trigger="true"][data-composer-navigation-target="reasoning"]'; + const readAuthority = () => { + const root = document.getElementById('root'); + if (!root) return undefined; + let rootKeys; + try { rootKeys = Object.getOwnPropertyNames(root).filter((key) => key.startsWith('__reactContainer$')); } + catch { return undefined; } + if (rootKeys.length !== 1) return undefined; + const reactRoot = readOwnDataProperty(root, rootKeys[0]); + return reactRoot?.exists + ? readActiveReasoningMetadata(document.querySelectorAll(reasoningSelector), reactRoot.value) + : undefined; + }; + const readSeam = (metadata) => metadata + ? readModelPresetSelector(document.querySelectorAll(reasoningSelector), metadata.modelCatalog) + : undefined; + let metadata = readAuthority(); + if (guardState.uncertain) { + if (guardState.uncertain.targetModelId) { + if (!metadata || metadata.modelId !== guardState.uncertain.targetModelId || + metadata.currentEffort !== guardState.uncertain.targetEffort) return 'metadata-unavailable'; + } else if (!metadata || (metadata.modelId === guardState.uncertain.modelId && + metadata.currentEffort === guardState.uncertain.currentEffort)) return 'metadata-unavailable'; + guardState.uncertain = null; + } + if (!metadata) return 'metadata-unavailable'; + const targetModel = metadata.modelCatalog.find((entry) => entry.modelId === request.modelId); + if (!targetModel || !targetModel.supportedReasoningEfforts.includes(request.reasoningEffort) || + (!request.includeUltra && request.reasoningEffort === 'ultra')) return 'metadata-unavailable'; + const selector = readSeam(metadata); + if (!selector || selector.modelId !== metadata.modelId || + selector.reasoningEffort !== metadata.currentEffort) return 'metadata-unavailable'; + const selectorTarget = selector.modelCatalog.find((entry) => entry.modelId === request.modelId); + if (!selectorTarget || !selectorTarget.supportedReasoningEfforts.includes(request.reasoningEffort)) { + return 'metadata-unavailable'; + } + if (metadata.modelId === request.modelId && metadata.currentEffort === request.reasoningEffort) { + return { modelId: metadata.modelId, reasoningEffort: metadata.currentEffort }; + } + guardState.uncertain = { + modelId: metadata.modelId, + currentEffort: metadata.currentEffort, + targetModelId: request.modelId, + targetEffort: request.reasoningEffort + }; + try { selector.select(request.modelId, request.reasoningEffort); } + catch { return 'metadata-unavailable'; } + const readConfirmedPair = () => { + const confirmedMetadata = readAuthority(); + const confirmedSelector = readSeam(confirmedMetadata); + return confirmedMetadata && confirmedSelector && + confirmedMetadata.modelId === request.modelId && + confirmedMetadata.currentEffort === request.reasoningEffort && + confirmedSelector.modelId === request.modelId && + confirmedSelector.reasoningEffort === request.reasoningEffort + ? { modelId: request.modelId, reasoningEffort: request.reasoningEffort } + : undefined; + }; + let confirmed = readConfirmedPair(); + if (confirmed) { + guardState.uncertain = null; + return confirmed; + } + await Promise.resolve(); + confirmed = readConfirmedPair(); + if (confirmed) { + guardState.uncertain = null; + return confirmed; + } + for (let attempt = 0; attempt < 8; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 8)); + confirmed = readConfirmedPair(); + if (confirmed) { + guardState.uncertain = null; + return confirmed; + } + } + return 'metadata-unavailable'; + } finally { + releaseGuard(); + } + })()`; + let result: ModelPresetExecution | "metadata-unavailable"; + try { + result = await this.evaluate(expression); + } catch (error) { + this.disconnect(); + throw error; + } + if (result === "metadata-unavailable") throw new Error("Codex model preset metadata or selector is unavailable."); + return result; } async runKeycap(keycapId: OfficialKeycapId): Promise { @@ -431,6 +1677,9 @@ export class CodexMicroRendererBridge { if (!action) throw new Error('The selected Codex Micro keycap has no action.'); if (action.type === 'command') { + if (action.command === 'composer.increaseReasoningEffort' || action.command === 'composer.decreaseReasoningEffort') { + throw new Error('Reasoning commands require the dedicated guarded controls.'); + } let commandRunner = null; if (commandsUrl) { const commands = await import(commandsUrl); @@ -438,22 +1687,9 @@ export class CodexMicroRendererBridge { } if (!commandRunner && bridgeUrl) { const bridgeSource = await (await fetch(bridgeUrl)).text(); - const runnerMatch = bridgeSource.match(/([A-Za-z_$][\\w$]*)\\(\\s*[A-Za-z_$][\\w$]*\\??\\.command\\s*,["'\\x60]codex_micro_hid["'\\x60]\\)/); - const runnerLocal = runnerMatch?.[1]; - const importPattern = /import\\s*\\{([^}]*)\\}\\s*from\\s*["']([^"']+)["']/g; - let importMatch; - while (runnerLocal && (importMatch = importPattern.exec(bridgeSource))) { - for (const specifier of importMatch[1].split(',')) { - const parts = specifier.trim().split(/\\s+as\\s+/); - const exportName = parts[0]; - const localName = parts[1] ?? parts[0]; - if (localName !== runnerLocal) continue; - const namespace = await import(new URL(importMatch[2], bridgeUrl).href); - if (typeof namespace[exportName] === 'function') commandRunner = namespace[exportName]; - break; - } - if (commandRunner) break; - } + const resolveCommandRunner = (${resolveCommandRunner.toString()}); + const resolveKeycapCommandRunner = (${resolveKeycapCommandRunner.toString()}); + commandRunner = await resolveKeycapCommandRunner(action.command, bridgeSource, bridgeUrl, (url) => import(url)); } if (typeof commandRunner !== 'function') throw new Error('Codex command runner is unavailable.'); const handled = commandRunner(action.command, 'codex_micro_hid'); @@ -487,6 +1723,7 @@ export class CodexMicroRendererBridge { await this.ensureConnected(); const redeemRequestId = randomUUID(); const expression = `(async () => { + const hasApplicableResetCredit = (${hasApplicableResetCredit.toString()}); const urls = [...new Set([ ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), ...performance.getEntriesByType('resource').map((entry) => entry.name) @@ -505,8 +1742,9 @@ export class CodexMicroRendererBridge { if (!client) throw new Error('Codex usage client is unavailable.'); const summary = await client.safeGet('/wham/usage'); - const applicable = Number(summary?.rate_limit_reset_credits?.applicable_available_count); - if (Number.isFinite(applicable) && applicable <= 0) throw new Error('No reset credit is currently applicable.'); + if (!hasApplicableResetCredit(summary?.rate_limit_reset_credits?.applicable_available_count)) { + throw new Error('No reset credit is currently applicable.'); + } const details = await client.safeGet('/wham/rate-limit-reset-credits'); const credit = Array.isArray(details?.credits) @@ -666,6 +1904,7 @@ export class CodexMicroRendererBridge { if (expected && this.socket !== expected) return; const socket = this.socket; this.socket = undefined; + this.evaluationNamespace = randomUUID(); if (socket && socket.readyState === WebSocket.OPEN) socket.close(); for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); diff --git a/src/codex-relay-client.ts b/src/codex-relay-client.ts index fe00269..4e9fe76 100644 --- a/src/codex-relay-client.ts +++ b/src/codex-relay-client.ts @@ -5,16 +5,21 @@ import WebSocket from "ws"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; import { isAllowedRelayHost } from "./relay-network.js"; import { - RELAY_PROTOCOL_VERSION, normalizeHostSnapshotAtReceipt, parseRelayServerMessage, + RELAY_MODEL_PRESETS_CAPABILITY, RELAY_PROTOCOL_VERSION, RELAY_REASONING_FEEDBACK_CAPABILITY, + RELAY_REASONING_POLICY_CAPABILITY, + normalizeHostSnapshotAtReceipt, parseRelayServerMessage, type HostSnapshot, type RelayCommand, type RelayResultMessage } from "./relay-protocol.js"; -import type { CodexHost, HostHealth } from "./types.js"; +import type { + CodexHost, HostHealth, ModelPresetExecution, ReasoningAdjustmentExecution, ReasoningAdjustmentResult +} from "./types.js"; export type RelayClientConfig = { enabled: boolean; url: string; token: string }; const CONFIG_PATH = join(codexDeckStateRoot(), "relay-client.json"); export const RELAY_SNAPSHOT_STALE_MS = 5_000; export const RELAY_COMMAND_TIMEOUT_MS = 10_000; +export const RELAY_MAX_PENDING_COMMANDS = 128; export function resolveRelayHealth(health: HostHealth, hasSnapshot: boolean, lastSnapshotReceivedAt: number, now = Date.now()): HostHealth { if (health.state === "ready" && (!hasSnapshot || now - lastSnapshotReceivedAt > RELAY_SNAPSHOT_STALE_MS)) { @@ -31,8 +36,28 @@ export class CodexRelayClient { private host?: CodexHost; private snapshot?: HostSnapshot; private lastSnapshotReceivedAt = 0; + private capabilities = new Set(); + private connectionGeneration = 0; + private readyGeneration = 0; + private readyHostId?: string; + private readyPlatform?: CodexHost["platform"]; + private identityViolationGeneration = 0; + private snapshotGeneration = 0; + private snapshotRevision = 0; private health: HostHealth = { state: "connecting", reason: "awaiting-snapshot", changedAt: Date.now() }; - private readonly pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); + private readonly pending = new Map void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + }>(); constructor( private readonly config: RelayClientConfig, @@ -48,6 +73,12 @@ export class CodexRelayClient { this.reconnect = undefined; this.socket?.close(1000, "client stopping"); this.socket = undefined; + this.capabilities.clear(); + this.readyGeneration = 0; + this.readyHostId = undefined; + this.readyPlatform = undefined; + this.snapshotRevision = 0; + this.identityViolationGeneration = 0; this.health = { state: "offline", reason: "relay-disconnected", changedAt: Date.now() }; this.rejectPending("Remote Codex relay disconnected."); } @@ -57,19 +88,99 @@ export class CodexRelayClient { currentHealth(now = Date.now()): HostHealth { return resolveRelayHealth(this.health, this.snapshot != null, this.lastSnapshotReceivedAt, now); } - isConnected(): boolean { return this.socket?.readyState === WebSocket.OPEN && this.host != null; } + isConnected(): boolean { + return this.socket?.readyState === WebSocket.OPEN && + this.identityViolationGeneration !== this.connectionGeneration && + this.readyGeneration === this.connectionGeneration && + this.readyHostId != null && this.readyPlatform != null && + this.host?.hostId === this.readyHostId && this.host.platform === this.readyPlatform; + } + supportsCapability(capability: string): boolean { return this.capabilities.has(capability); } + supportsCurrentReadyCapability(capability: string): boolean { + return this.socket?.readyState === WebSocket.OPEN && + this.identityViolationGeneration !== this.connectionGeneration && + this.readyGeneration === this.connectionGeneration && + this.readyHostId != null && this.readyPlatform != null && + this.host?.hostId === this.readyHostId && this.host.platform === this.readyPlatform && + this.capabilities.has(capability); + } + supportsCapabilityForSnapshot( + capability: string, + hostId: string, + platform: CodexHost["platform"] + ): boolean { + return this.socket?.readyState === WebSocket.OPEN && + this.currentHealth().state === "ready" && + this.identityViolationGeneration !== this.connectionGeneration && + this.readyGeneration === this.connectionGeneration && + this.snapshotGeneration === this.connectionGeneration && + this.readyHostId === hostId && this.readyPlatform === platform && + this.host?.hostId === hostId && this.host.platform === platform && + this.snapshot?.host.hostId === hostId && this.snapshot.host.platform === platform && + this.capabilities.has(capability); + } - async send(command: RelayCommand): Promise { + async send( + command: RelayCommand + ): Promise { const socket = this.socket; - if (!socket || socket.readyState !== WebSocket.OPEN || !this.host) throw new Error("Remote Codex host is offline."); + if (!socket || socket.readyState !== WebSocket.OPEN || + this.identityViolationGeneration === this.connectionGeneration || + this.readyGeneration !== this.connectionGeneration || + this.readyHostId == null || this.readyPlatform == null || + this.host?.hostId !== this.readyHostId || this.host.platform !== this.readyPlatform) { + throw new Error("Remote Codex host is offline."); + } + if (this.pending.size >= RELAY_MAX_PENDING_COMMANDS) { + throw new Error("Remote Codex relay has too many pending commands."); + } + const supportsReasoningPolicy = this.capabilities.has(RELAY_REASONING_POLICY_CAPABILITY); + if (command.kind === "model-preset" && + !this.supportsCapabilityForSnapshot( + RELAY_MODEL_PRESETS_CAPABILITY, this.readyHostId, this.readyPlatform + )) { + throw new Error("Remote Codex host does not support model preset controls."); + } + if (command.kind === "reasoning" && !command.includeUltra && !supportsReasoningPolicy) { + throw new Error("Remote Codex host does not support reasoning policy controls."); + } + const legacyUnrestrictedReasoning = command.kind === "reasoning" && + command.includeUltra && !supportsReasoningPolicy; + const reasoningFeedbackOptIn = command.kind === "reasoning" && + command.includeReasoningFeedback === true && + this.capabilities.has(RELAY_REASONING_FEEDBACK_CAPABILITY); + const wireCommand: RelayCommand = command.kind === "reasoning" && !reasoningFeedbackOptIn + ? { + kind: "reasoning", direction: command.direction, + includeUltra: command.includeUltra + } + : command; const requestId = randomUUID(); - await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(requestId); reject(new Error("Remote Codex command timed out.")); }, RELAY_COMMAND_TIMEOUT_MS); - this.pending.set(requestId, { resolve, reject, timer }); - socket.send(JSON.stringify({ type: "command", protocol: RELAY_PROTOCOL_VERSION, requestId, command })); + this.pending.set(requestId, { + commandKind: command.kind, legacyUnrestrictedReasoning, reasoningFeedbackOptIn, + requestedModelPreset: command.kind === "model-preset" + ? { modelId: command.modelId, reasoningEffort: command.reasoningEffort } + : undefined, + snapshotRevision: this.snapshotRevision, + connectionGeneration: this.connectionGeneration, + readyHostId: this.readyHostId!, + readyPlatform: this.readyPlatform!, + resolve, reject, timer + }); + try { + socket.send(JSON.stringify({ + type: "command", protocol: RELAY_PROTOCOL_VERSION, requestId, command: wireCommand + })); + } catch (error) { + clearTimeout(timer); + this.pending.delete(requestId); + reject(error); + } }); } @@ -78,10 +189,11 @@ export class CodexRelayClient { this.connecting = true; this.health = { state: "connecting", reason: "awaiting-snapshot", changedAt: Date.now() }; try { + const generation = ++this.connectionGeneration; const socket = new WebSocket(this.config.url, { handshakeTimeout: 4_000, maxPayload: 64 * 1024, perMessageDeflate: false }); this.socket = socket; socket.on("open", () => socket.send(JSON.stringify({ type: "auth", protocol: RELAY_PROTOCOL_VERSION, token: this.config.token }))); - socket.on("message", (raw) => this.handleMessage(raw.toString())); + socket.on("message", (raw) => this.handleMessage(raw.toString(), generation)); socket.on("close", () => this.disconnected(socket)); socket.on("error", () => this.disconnected(socket)); } catch (error) { @@ -91,17 +203,35 @@ export class CodexRelayClient { } finally { this.connecting = false; } } - private handleMessage(raw: string): void { + private handleMessage(raw: string, generation: number): void { + if (generation !== this.connectionGeneration) return; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return; } const message = parseRelayServerMessage(parsed); if (!message) return; + if (this.identityViolationGeneration === generation) return; if (message.type === "ready") { + if (this.readyGeneration === generation && + (this.readyHostId !== message.host.hostId || + this.readyPlatform !== message.host.platform)) { + this.invalidateIdentity(generation); + return; + } + this.readyHostId = message.host.hostId; + this.readyPlatform = message.host.platform; this.host = message.host; + this.capabilities = new Set(message.capabilities ?? []); + this.readyGeneration = generation; this.health = { state: "degraded", reason: "awaiting-snapshot", changedAt: Date.now() }; this.log(`Remote Codex host connected: ${message.host.hostName} (${message.host.platform}).`); } else if (message.type === "snapshot") { + if (this.readyGeneration !== generation) return; + if (this.readyHostId !== message.host.hostId || + this.readyPlatform !== message.host.platform) { + this.invalidateIdentity(generation); + return; + } const receivedAt = Date.now(); this.host = message.host; this.snapshot = normalizeHostSnapshotAtReceipt( @@ -109,9 +239,17 @@ export class CodexRelayClient { receivedAt ); this.lastSnapshotReceivedAt = receivedAt; + this.snapshotGeneration = generation; + this.snapshotRevision += 1; this.health = { state: "ready", changedAt: receivedAt }; this.onSnapshot(this.snapshot); } else if (message.type === "health") { + if (this.readyGeneration !== generation) return; + if (this.readyHostId !== message.host.hostId || + this.readyPlatform !== message.host.platform) { + this.invalidateIdentity(generation); + return; + } this.host = message.host; this.health = { state: "degraded", reason: message.reason, changedAt: Date.now() }; } else this.handleResult(message); @@ -122,13 +260,129 @@ export class CodexRelayClient { if (!pending) return; this.pending.delete(message.requestId); clearTimeout(pending.timer); - if (message.ok) pending.resolve(); - else pending.reject(new Error(message.error || "Remote Codex command failed.")); + if (!message.ok) { + pending.reject(new Error(message.error || "Remote Codex command failed.")); + return; + } + if ("modelId" in message) { + const requested = pending.requestedModelPreset; + if (pending.commandKind !== "model-preset" || !requested || + message.modelId !== requested.modelId || + message.reasoningEffort !== requested.reasoningEffort) { + pending.reject(new Error("Remote Codex returned unexpected model preset feedback.")); + return; + } + if (pending.connectionGeneration !== this.connectionGeneration || + pending.connectionGeneration !== this.readyGeneration || + pending.readyHostId !== this.readyHostId || + pending.readyPlatform !== this.readyPlatform || + pending.readyHostId !== this.host?.hostId || + pending.readyPlatform !== this.host?.platform || + this.currentHealth().state !== "ready" || + !this.capabilities.has(RELAY_MODEL_PRESETS_CAPABILITY) || + this.snapshotGeneration !== this.connectionGeneration || + this.snapshotRevision <= pending.snapshotRevision || + this.snapshot?.host.hostId !== pending.readyHostId || + this.snapshot.host.platform !== pending.readyPlatform) { + pending.reject(new Error("Remote Codex returned stale model preset feedback.")); + return; + } + const current = this.snapshot; + if (current.snapshot.activeModelId !== message.modelId || + current.snapshot.reasoningEffort !== message.reasoningEffort) { + pending.reject(new Error( + "Remote Codex model preset result disagrees with the authoritative post-command snapshot." + )); + return; + } + const catalogEntry = current.snapshot.modelCatalog?.find((entry) => + entry.modelId === message.modelId && + entry.supportedReasoningEfforts.includes(message.reasoningEffort)); + if (!catalogEntry) { + pending.reject(new Error("Remote Codex model preset is absent from the authoritative catalog.")); + return; + } + this.snapshot = { + ...current, + snapshot: { + ...current.snapshot, + activeModelId: message.modelId, + activeModelDisplayName: catalogEntry.displayName, + reasoningEffort: message.reasoningEffort + } + }; + this.onSnapshot(this.snapshot); + pending.resolve({ modelId: message.modelId, reasoningEffort: message.reasoningEffort }); + return; + } + if (pending.commandKind === "model-preset") { + pending.reject(new Error("Remote Codex omitted requested model preset feedback.")); + return; + } + if (message.reasoningEffort !== undefined) { + if (pending.commandKind !== "reasoning" || !pending.reasoningFeedbackOptIn) { + pending.reject(new Error("Remote Codex returned unexpected reasoning feedback.")); + return; + } + if (pending.connectionGeneration !== this.connectionGeneration || + pending.connectionGeneration !== this.readyGeneration || + pending.readyHostId !== this.readyHostId || + pending.readyPlatform !== this.readyPlatform || + pending.readyHostId !== this.host?.hostId || + pending.readyPlatform !== this.host?.platform || + this.snapshotGeneration !== this.connectionGeneration || + this.snapshot?.host.hostId !== pending.readyHostId || + this.snapshot.host.platform !== pending.readyPlatform) { + pending.reject(new Error("Remote Codex returned stale reasoning feedback.")); + return; + } + } + if (pending.commandKind === "reasoning" && + message.outcome !== "applied" && message.outcome !== "blocked-ultra") { + if (pending.legacyUnrestrictedReasoning && message.outcome === undefined) { + pending.resolve("applied"); + return; + } + pending.reject(new Error("Remote Codex returned an invalid reasoning adjustment result.")); + return; + } + if (pending.reasoningFeedbackOptIn && message.reasoningEffort === undefined) { + pending.reject(new Error("Remote Codex omitted requested reasoning feedback.")); + return; + } + if (message.reasoningEffort !== undefined) { + const current = this.snapshot!; + this.snapshot = { + ...current, + snapshot: { ...current.snapshot, reasoningEffort: message.reasoningEffort } + }; + this.onSnapshot(this.snapshot); + pending.resolve({ + outcome: message.outcome!, reasoningEffort: message.reasoningEffort + }); + return; + } + pending.resolve(message.outcome); + } + + private invalidateIdentity(generation: number): void { + if (generation !== this.connectionGeneration || + this.identityViolationGeneration === generation) return; + this.identityViolationGeneration = generation; + this.capabilities.clear(); + this.health = { state: "offline", reason: "relay-disconnected", changedAt: Date.now() }; + this.rejectPending("Remote Codex relay identity changed."); } private disconnected(expected: WebSocket): void { if (this.socket !== expected) return; this.socket = undefined; + this.capabilities.clear(); + this.readyGeneration = 0; + this.readyHostId = undefined; + this.readyPlatform = undefined; + this.snapshotRevision = 0; + this.identityViolationGeneration = 0; this.health = { state: "offline", reason: "relay-disconnected", changedAt: Date.now() }; this.rejectPending("Remote Codex relay disconnected."); this.scheduleReconnect(); diff --git a/src/codex-relay-server.ts b/src/codex-relay-server.ts index 9c72699..858832e 100644 --- a/src/codex-relay-server.ts +++ b/src/codex-relay-server.ts @@ -7,11 +7,14 @@ import { WebSocketServer, WebSocket } from "ws"; import type { OfficialKeycapId } from "./keycaps.js"; import type { CodexMicroRendererBridge } from "./codex-micro-renderer-bridge.js"; import { - RELAY_CAPABILITIES, RELAY_PROTOCOL_VERSION, parseRelayCommand, + RELAY_CAPABILITIES, RELAY_PROTOCOL_VERSION, parseRelayCommandMessage, type RelayAuthMessage, type RelayCommand, type RelayCommandMessage, type RelayHealthMessage, type RelayResultMessage, type RelaySnapshotMessage } from "./relay-protocol.js"; -import type { CodexHost } from "./types.js"; +import { + isSafeReasoningIdentifier, + type CodexHost, type ModelPresetExecution, type ReasoningAdjustmentExecution +} from "./types.js"; export type RelayServerConfig = { enabled: boolean; @@ -27,8 +30,27 @@ export type RelayServerConfig = { discovery?: { enabled: boolean }; }; +const RELAY_MAX_PAYLOAD_BYTES = 64 * 1024; + +export function encodeRelaySnapshotMessage(message: RelaySnapshotMessage): string { + const encoded = JSON.stringify(message); + if (Buffer.byteLength(encoded, "utf8") < RELAY_MAX_PAYLOAD_BYTES) return encoded; + const { + activeModelId: _activeModelId, + activeModelDisplayName: _activeModelDisplayName, + modelCatalog: _modelCatalog, + ...snapshot + } = message.snapshot; + const bounded = JSON.stringify({ ...message, snapshot }); + if (Buffer.byteLength(bounded, "utf8") >= RELAY_MAX_PAYLOAD_BYTES) { + throw new Error("Relay snapshot exceeds the 64 KiB transport limit without its optional model catalog."); + } + return bounded; +} + type RelayControl = Pick; + "refresh" | "sendAgent" | "sendAction" | "sendJoystick" | "sendEncoder" | "adjustReasoning" | "runKeycap" | + "refreshUsage" | "consumeRateLimitReset"> & Pick, "applyModelPreset">; export class CodexRelayServer { private server?: WebSocketServer; @@ -177,7 +199,10 @@ export class CodexRelayServer { this.authenticated.add(socket); socket.send(JSON.stringify({ type: "ready", protocol: RELAY_PROTOCOL_VERSION, host: this.host, - capabilities: RELAY_CAPABILITIES, bridge: "native-codex-micro" + capabilities: this.control.applyModelPreset + ? RELAY_CAPABILITIES + : RELAY_CAPABILITIES.filter((capability) => capability !== "model-presets"), + bridge: "native-codex-micro" })); socket.on("message", (message) => { void this.handleMessage(socket, message.toString()).catch((error) => this.reportSnapshotError(error)); @@ -190,23 +215,34 @@ export class CodexRelayServer { } private async handleMessage(socket: WebSocket, raw: string): Promise { - const message = safeJson(raw) as Partial | null; - if (!message || message.type !== "command" || message.protocol !== RELAY_PROTOCOL_VERSION || typeof message.requestId !== "string") return; - const command = parseRelayCommand(message.command); - if (!command) { - this.sendResult(socket, message.requestId, false, "Invalid relay command."); - return; - } + const message = parseRelayCommandMessage(safeJson(raw)); + if (!message) return; + const command = message.command; const startedAt = Date.now(); const commandLabel = command.kind === "agent" ? `agent:${command.slot + 1}:${command.act === 1 ? "down" : "up"}` : command.kind; this.log(`Relay command ${commandLabel} received.`); try { - await executeRelayCommand(this.control, command); - this.sendResult(socket, message.requestId, true); + const execution = validatedRelayExecution(command, await executeRelayCommand(this.control, command)); + if (command.kind === "usage-refresh") await this.publishSnapshot(undefined, true); + if (command.kind === "reasoning" && command.includeReasoningFeedback === true) { + await this.publishSnapshot(undefined, true); + } + if (command.kind === "model-preset") { + try { await this.publishSnapshot(undefined, true); } + catch (error) { + this.handleSnapshotFailure(error, undefined, true); + throw error; + } + } + this.sendResult(socket, message.requestId, true, undefined, execution); this.log(`Relay command ${commandLabel} completed in ${Date.now() - startedAt} ms.`); - await this.publishSnapshot(); + if (command.kind !== "usage-refresh" && + !(command.kind === "reasoning" && command.includeReasoningFeedback === true) && + command.kind !== "model-preset") { + await this.publishSnapshot(); + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.log(`Relay command ${commandLabel} failed in ${Date.now() - startedAt} ms: ${errorMessage}`); @@ -214,9 +250,27 @@ export class CodexRelayServer { } } - private sendResult(socket: WebSocket, requestId: string, ok: boolean, error?: string): void { + private sendResult( + socket: WebSocket, + requestId: string, + ok: boolean, + error?: string, + execution?: ReasoningAdjustmentExecution | ModelPresetExecution + ): void { if (socket.readyState !== WebSocket.OPEN) return; - const result: RelayResultMessage = { type: "result", protocol: RELAY_PROTOCOL_VERSION, requestId, ok, ...(error ? { error } : {}) }; + const result: RelayResultMessage = ok + ? { + type: "result", protocol: RELAY_PROTOCOL_VERSION, requestId, ok: true, + ...(!execution || !("outcome" in execution) ? {} : { outcome: execution.outcome }), + ...(!execution || !("reasoningEffort" in execution) ? {} : { + reasoningEffort: execution.reasoningEffort + }), + ...(!execution || !("modelId" in execution) ? {} : { modelId: execution.modelId }) + } + : { + type: "result", protocol: RELAY_PROTOCOL_VERSION, requestId, ok: false, + ...(error ? { error: error.slice(0, 512) } : {}) + }; socket.send(JSON.stringify(result)); } @@ -238,10 +292,10 @@ export class CodexRelayServer { this.log(`Relay snapshot unavailable: ${message}`); } - private handleSnapshotFailure(error: unknown, only?: WebSocket): void { + private handleSnapshotFailure(error: unknown, only?: WebSocket, forceDegraded = false): void { this.reportSnapshotError(error); this.consecutiveSnapshotFailures += 1; - if (!relaySnapshotFailureShouldDegrade( + if (!forceDegraded && !relaySnapshotFailureShouldDegrade( this.hasPublishedSnapshot, this.consecutiveSnapshotFailures )) return; const health: RelayHealthMessage = { @@ -260,8 +314,12 @@ export class CodexRelayServer { } } - private async publishSnapshot(only?: WebSocket): Promise { - const message = await this.currentSnapshotMessage(); + private async publishSnapshot(only?: WebSocket, forceFresh = false): Promise { + const message = await this.currentSnapshotMessage(forceFresh); + const encoded = encodeRelaySnapshotMessage(message); + for (const socket of only ? [only] : this.authenticated) { + if (socket.readyState === WebSocket.OPEN) socket.send(encoded); + } if (this.consecutiveSnapshotFailures > 0) { this.log(`Relay snapshot recovered after ${this.consecutiveSnapshotFailures} transient failure${this.consecutiveSnapshotFailures === 1 ? "" : "s"}.`); } @@ -270,21 +328,24 @@ export class CodexRelayServer { this.degraded = false; this.lastSnapshotError = ""; this.lastSnapshotErrorAt = 0; - const encoded = JSON.stringify(message); - for (const socket of only ? [only] : this.authenticated) { - if (socket.readyState === WebSocket.OPEN) socket.send(encoded); - } } - private async currentSnapshotMessage(): Promise { - if (this.snapshotInFlight) return this.snapshotInFlight; - const pending = this.control.refresh().then((snapshot): RelaySnapshotMessage => ({ - type: "snapshot", - protocol: RELAY_PROTOCOL_VERSION, - host: this.host, - observedAt: Date.now(), - snapshot - })); + private async currentSnapshotMessage(forceFresh = false): Promise { + const previous = this.snapshotInFlight; + if (previous && !forceFresh) return previous; + const pending = (async (): Promise => { + if (previous) { + try { await previous; } catch {} + } + const snapshot = await this.control.refresh(); + return { + type: "snapshot", + protocol: RELAY_PROTOCOL_VERSION, + host: this.host, + observedAt: Date.now(), + snapshot + }; + })(); this.snapshotInFlight = pending; try { return await pending; } finally { if (this.snapshotInFlight === pending) this.snapshotInFlight = undefined; } @@ -347,14 +408,129 @@ export function relayDiscoveryTxt( }; } -async function executeRelayCommand(control: RelayControl, command: RelayCommand): Promise { - if (command.kind === "agent") return control.sendAgent(command.slot, command.act, command.threadKey); - if (command.kind === "action") return control.sendAction(command.slot, command.act); - if (command.kind === "joystick") return control.sendJoystick(command.direction, command.distance); - if (command.kind === "encoder") return control.sendEncoder(command.act); - if (command.kind === "reasoning") return control.adjustReasoning(command.direction); - if (command.kind === "rate-limit-reset") return control.consumeRateLimitReset(); - return control.runKeycap(command.keycapId as OfficialKeycapId); +async function executeRelayCommand( + control: RelayControl, + command: RelayCommand +): Promise { + if (command.kind === "agent") { + await control.sendAgent(command.slot, command.act, command.threadKey); + return undefined; + } + if (command.kind === "action") { + await control.sendAction(command.slot, command.act); + return undefined; + } + if (command.kind === "joystick") { + await control.sendJoystick(command.direction, command.distance); + return undefined; + } + if (command.kind === "encoder") { + await control.sendEncoder(command.act); + return undefined; + } + if (command.kind === "reasoning") { + return control.adjustReasoning(command.direction, { includeUltra: command.includeUltra }); + } + if (command.kind === "model-preset") { + if (!control.applyModelPreset) throw new Error("Model preset controls are unavailable."); + return control.applyModelPreset({ + modelId: command.modelId, + reasoningEffort: command.reasoningEffort, + includeUltra: command.includeUltra + }); + } + if (command.kind === "usage-refresh") { + await control.refreshUsage(); + return undefined; + } + if (command.kind === "rate-limit-reset") { + await control.consumeRateLimitReset(); + return undefined; + } + await control.runKeycap(command.keycapId as OfficialKeycapId); + return undefined; +} + +function validatedRelayExecution( + command: RelayCommand, + value: unknown +): ReasoningAdjustmentExecution | ModelPresetExecution | undefined { + if (command.kind === "reasoning") { + let outcome: unknown; + let reasoningEffort: unknown; + try { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid reasoning adjustment result."); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("Invalid reasoning adjustment result."); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + const expectedKeys = ["outcome", ...(Object.prototype.hasOwnProperty.call(descriptors, "reasoningEffort") + ? ["reasoningEffort"] : [])]; + if (keys.length !== expectedKeys.length || keys.some((key) => + typeof key !== "string" || !expectedKeys.includes(key))) { + throw new Error("Invalid reasoning adjustment result."); + } + const outcomeProperty = descriptors.outcome; + const effortProperty = descriptors.reasoningEffort; + if (!outcomeProperty || outcomeProperty.enumerable !== true || !("value" in outcomeProperty) || + (effortProperty && (effortProperty.enumerable !== true || !("value" in effortProperty)))) { + throw new Error("Invalid reasoning adjustment result."); + } + outcome = outcomeProperty.value; + reasoningEffort = effortProperty?.value; + } catch { + throw new Error("Invalid reasoning adjustment result."); + } + if (outcome === "applied" || outcome === "blocked-ultra") { + if (command.includeReasoningFeedback === true && reasoningEffort === undefined) { + throw new Error("Invalid reasoning adjustment result."); + } + if (reasoningEffort !== undefined && !isSafeReasoningIdentifier(reasoningEffort)) { + throw new Error("Invalid reasoning adjustment result."); + } + return { + outcome, + ...(command.includeReasoningFeedback === true && reasoningEffort !== undefined + ? { reasoningEffort } : {}) + }; + } + } + if (command.kind === "model-preset") { + const record = exactExecutionRecord(value, ["modelId", "reasoningEffort"]); + if (record && isSafeReasoningIdentifier(record.modelId, 128) && + isSafeReasoningIdentifier(record.reasoningEffort) && + record.modelId === command.modelId && + record.reasoningEffort === command.reasoningEffort) { + return { modelId: record.modelId, reasoningEffort: record.reasoningEffort }; + } + throw new Error("Invalid model preset result."); + } + if (command.kind !== "reasoning" && value === undefined) return undefined; + throw new Error("Invalid reasoning adjustment result."); +} + +function exactExecutionRecord( + value: unknown, + expectedKeys: readonly string[] +): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if (keys.length !== expectedKeys.length || keys.some((key) => + typeof key !== "string" || !expectedKeys.includes(key))) return undefined; + const result: Record = Object.create(null) as Record; + for (const key of expectedKeys) { + const descriptor = descriptors[key]; + if (!descriptor || descriptor.enumerable !== true || !("value" in descriptor)) return undefined; + result[key] = descriptor.value; + } + return result; } function secureEqual(left: unknown, right: string): boolean { diff --git a/src/controller.ts b/src/controller.ts index ae08aac..8ccafce 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1,4 +1,5 @@ -import streamDeck, { type KeyAction } from "@elgato/streamdeck"; +import streamDeck, { type DialAction, type KeyAction } from "@elgato/streamdeck"; +import type { JsonValue } from "@elgato/utils"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; @@ -10,17 +11,46 @@ import { CodexRelayClient, readRelayClientConfig } from "./codex-relay-client.js import { CodexRelayServer, readRelayServerConfig } from "./codex-relay-server.js"; import { CodexMicroRendererBridge } from "./codex-micro-renderer-bridge.js"; import { getOrCreateHostIdentity } from "./host-identity.js"; -import type { OfficialKeycapId } from "./keycaps.js"; -import { HostActivityIndex, type HostSnapshot, type RelayCommand } from "./relay-protocol.js"; +import { ADDITIONAL_KEYCAPS, type OfficialKeycapId } from "./keycaps.js"; +import { + DialCommandQueue, + MAX_DIAL_QUEUE_PENDING, + bindingLifecycle, + deriveDialFeedback, + dialBindingLabel, + initialDialRuntimeState, + isDialTickCount, + isDialBindingId, + normalizeDialSettings, + reconcileSelector, + reduceDialRotation, + resolveModelPresetDirection, + selectedItem, + selectorItems +} from "./dial-domain.js"; +import type { + CodexDialSettings, + DialBindingId, + DialRuntimeState, + DialRuntimeView, + DialSelectorItem, + ModelPresetDirection +} from "./dial-types.js"; +import { + HostActivityIndex, RELAY_MODEL_PRESETS_CAPABILITY, RELAY_REASONING_POLICY_CAPABILITY, + type HostSnapshot, type RelayCommand +} from "./relay-protocol.js"; import { renderAgentKey, renderBuiltinKeycap, renderFallbackKeycap, renderHostTargetKey, renderImportedKeycap, renderRateLimitResetKey, renderUsageLimitKey, renderUsageOverviewKey, type BuiltinIconName } from "./render.js"; import { openCodexThread } from "./codex-open.js"; import { visualStatusFromMicro } from "./status.js"; +import { isSafeReasoningIdentifier } from "./types.js"; import type { - CodexHost, HostHealth, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, - RoutedAgentSlot, UsageLimitMode, UsageWindowKind + CodexHost, CodexModelCatalogEntry, HostHealth, MicroActionSlot, MicroDirection, MicroSnapshot, + ModelPresetExecution, ModelPresetRequest, ReasoningAdjustment, ReasoningAdjustmentExecution, + ReasoningAdjustmentPolicy, ReasoningAdjustmentResult, RoutedAgentSlot, UsageLimitMode, UsageWindowKind } from "./types.js"; import { selectAccountUsageSource, selectUsageWindow, type AccountUsageSource } from "./usage.js"; @@ -34,10 +64,101 @@ type MicroActionRegistration = { action: KeyAction; slot: MicroActionSlot }; type UsageLimitRegistration = { action: KeyAction; mode: UsageLimitMode }; type ActionIdentity = { id: string }; type ContextRingSettings = { showContextRings?: boolean }; +type CodexDialAction = DialAction & { + sendToPropertyInspector?(payload: JsonValue): Promise; +}; +type DialHostRoute = { kind: "host"; hostId?: string; platform: ControlTarget }; +type DialAgentRoute = { + kind: "agent"; + assignment: RoutedAgentSlot; + identity: string; +}; +type AgentSendOutcome = { ok: true } | { ok: false; error: unknown }; +type KeypadAgentGesture = { + assignment: RoutedAgentSlot; + outcome: Promise; +}; +type DialInvalidAgentRoute = { kind: "invalid-agent" }; +type DialRoute = DialHostRoute | DialAgentRoute | DialInvalidAgentRoute; +type DialGesturePhase = "pending" | "active" | "completed" | "failed" | "releasing" | "released" | "canceled"; +type DialGesture = { + binding: DialBindingId; + route: DialRoute; + includeUltraReasoning: boolean; + lifecycle: ReturnType; + startedAt: number; + endedAt?: number; + phase: DialGesturePhase; + modelReasoningReservation?: ModelReasoningMutationReservation; +}; +type ModelReasoningMutationReservation = { + released: boolean; + canceled: boolean; + claimed: boolean; + running: boolean; + predecessor: Promise; + completeTurn(): void; + owner?: Set; +}; +type DialRegistration = { + action: CodexDialAction; + settings: CodexDialSettings; + state: DialRuntimeState; + queue: DialCommandQueue; + generation: number; + disposed: boolean; + modelReasoningReservations: Set; + pressed?: DialGesture; + lastFeedback?: string; + renderAgain?: boolean; + rendering?: Promise; + noticeActive?: boolean; + noticeTimer?: NodeJS.Timeout; + noticeRevision: number; +}; +type DialInspectorRegistration = { + action: CodexDialAction; + generation: number; + lastSignature?: string; +}; +type ModelCatalogResponse = { + kind: "model-catalog"; + requestGeneration: number; + catalogRevision: number; + available: boolean; + hostId?: string; + platform?: ControlTarget; + snapshotGeneration?: number; + activeModelId?: string; + activeModelDisplayName?: string; + reasoningEffort?: string; + modelCatalog?: CodexModelCatalogEntry[]; +}; +type DialNotice = { + title: string; + value: string; + detail: string; + indicator: number; + accent: { value: number; bar_fill_c: string }; +}; +type ResetHold = { startedAt: number; sourceHostId?: string }; const USER_ICON_ROOT = join(codexDeckStateRoot(), "icons"); const LOCAL_MOBILE_CONFIG = "mobile-local-relay-server.json"; const RESET_HOLD_MS = 1_200; +const DIAL_SUCCESS_MS = 350; +const DIAL_ULTRA_NOTICE_MS = 1_200; +const MAX_DIAL_ERROR_DEDUPE = 100; + +function rememberDialError(errors: Set, message: string): boolean { + if (errors.has(message)) return false; + if (errors.size >= MAX_DIAL_ERROR_DEDUPE) { + const oldest = errors.values().next().value as string | undefined; + if (oldest != null) errors.delete(oldest); + } + errors.add(message); + return true; +} export class DeckController { private readonly microBridge = new CodexMicroRendererBridge((message) => streamDeck.logger.info(message)); @@ -50,9 +171,15 @@ export class DeckController { private readonly usageLimitActions = new Map(); private readonly usageOverviewActions = new Map(); private readonly rateLimitResetActions = new Map(); - private readonly resetHolds = new Map(); + private readonly dials = new Map(); + private readonly dialInspectors = new Map(); + private readonly resetHolds = new Map(); + private readonly dialDescriptionErrors = new Set(); + private readonly dialRenderErrors = new Set(); + private readonly dialSuccessErrors = new Set(); private readonly activityIndex = new HostActivityIndex(); - private readonly pressedAgents = new Map(); + private readonly pressedAgents = new Map(); + private readonly failedAgentUps = new Map(); private readonly pressedControlTargets = new Map(); private relayClient?: CodexRelayClient; private mobileRelayServer?: CodexRelayServer; @@ -66,6 +193,12 @@ export class DeckController { private poll?: NodeJS.Timeout; private animation?: NodeJS.Timeout; private refreshInFlight?: Promise; + private modelReasoningMutationTail: Promise = Promise.resolve(); + private modelReasoningMutationPending = 0; + private readonly modelReasoningLifecycleCancellations = new Set<() => void>(); + private localSnapshotGeneration = 0; + private committedLocalSnapshotGeneration = 0; + private controllerLifecycleGeneration = 0; private stopped = false; private animationFrame = 0; private lastError = ""; @@ -75,8 +208,13 @@ export class DeckController { private lastAgentSourceSignature = ""; private lastHostHealthSignature = ""; private showContextRings = true; + private nextDialGeneration = 0; + private nextDialInspectorGeneration = 0; + private catalogRevision = 0; async start(): Promise { + this.cancelModelReasoningLifecycle(); + this.controllerLifecycleGeneration += 1; this.stopped = false; try { const settings = await streamDeck.settings.getGlobalSettings(); @@ -94,7 +232,7 @@ export class DeckController { if (relayConfig) { this.relayClient = new CodexRelayClient( relayConfig, - () => { void this.refreshDisplay(); }, + () => { void this.refreshDisplayAfterRelaySnapshot(); }, (message) => streamDeck.logger.info(message) ); this.relayClient.start(); @@ -106,13 +244,11 @@ export class DeckController { ]); if (mobileRelayConfig || localMobileRelayConfig) { let mobileSnapshotDirty = false; - const runAndInvalidate = async (operation: () => Promise): Promise => { - await operation(); - // The relay server publishes a fresh snapshot after acknowledging the - // command. Do not make the command result wait for a second full - // controller refresh: a renderer refresh can take several seconds - // and remote clients intentionally use a short command timeout. + const runAndInvalidate = async (operation: () => Promise): Promise => { + const result = await operation(); + // The relay server publishes a fresh snapshot after the command. mobileSnapshotDirty = true; + return result; }; const mobileControl = { refresh: async () => { @@ -133,10 +269,16 @@ export class DeckController { sendJoystick: (direction: MicroDirection, distance: 0 | 1) => runAndInvalidate( () => this.microBridge.sendJoystick(direction, distance)), sendEncoder: (act: 0 | 1) => runAndInvalidate(() => this.microBridge.sendEncoder(act)), - adjustReasoning: (direction: ReasoningAdjustment) => runAndInvalidate( - () => this.microBridge.adjustReasoning(direction)), + adjustReasoning: (direction: ReasoningAdjustment, policy?: ReasoningAdjustmentPolicy) => runAndInvalidate( + () => this.adjustLocalReasoningFromRelay(direction, policy)), + applyModelPreset: (request: ModelPresetRequest) => runAndInvalidate( + () => this.applyLocalModelPresetFromRelay(request)), runKeycap: (keycapId: OfficialKeycapId) => runAndInvalidate( () => this.microBridge.runKeycap(keycapId)), + refreshUsage: async () => { + await this.refreshLocalUsage(); + mobileSnapshotDirty = false; + }, consumeRateLimitReset: () => runAndInvalidate(() => this.microBridge.consumeRateLimitReset()) }; if (mobileRelayConfig) { @@ -165,12 +307,19 @@ export class DeckController { } stop(): void { + this.controllerLifecycleGeneration += 1; this.stopped = true; + this.cancelModelReasoningLifecycle(); if (this.poll) clearInterval(this.poll); if (this.animation) clearInterval(this.animation); + for (const registration of [...this.dials.values()]) { + this.disposeDialRegistration(registration, false); + } this.relayClient?.close(); - void this.mobileRelayServer?.close(); - void this.localMobileRelayServer?.close(); + void this.mobileRelayServer?.close().catch((error) => + streamDeck.logger.error(`Mobile relay close failed: ${String(error)}`)); + void this.localMobileRelayServer?.close().catch((error) => + streamDeck.logger.error(`Nearby mobile relay close failed: ${String(error)}`)); this.microBridge.close(); } @@ -217,6 +366,640 @@ export class DeckController { this.lastImages.delete(action.id); } + registerDial(action: CodexDialAction, input: unknown): void { + const prior = this.dials.get(action.id); + if (prior) this.disposeDialRegistration(prior); + const registration: DialRegistration = { + action, + settings: normalizeDialSettings(input), + state: initialDialRuntimeState(), + queue: new DialCommandQueue(), + generation: ++this.nextDialGeneration, + disposed: false, + modelReasoningReservations: new Set(), + noticeRevision: 0 + }; + this.dials.set(action.id, registration); + this.updateDialDescription(registration); + void this.renderDialSafely(registration); + } + + updateDialSettings(action: CodexDialAction, input: unknown): void { + const settings = normalizeDialSettings(input); + let existing = this.dials.get(action.id); + if (existing && existing.action !== action) { + this.disposeDialRegistration(existing); + existing = undefined; + } + const registration: DialRegistration = existing ?? { + action, + settings, + state: initialDialRuntimeState(), + queue: new DialCommandQueue(), + generation: ++this.nextDialGeneration, + disposed: false, + modelReasoningReservations: new Set(), + noticeRevision: 0 + }; + this.clearDialNotice(registration); + registration.action = action; + registration.settings = settings; + this.dials.set(action.id, registration); + this.updateDialDescription(registration); + void this.renderDialSafely(registration); + } + + unregisterDial(action: ActionIdentity): void { + const registration = this.dials.get(action.id); + if (!registration) return; + this.disposeDialRegistration(registration); + } + + registerDialPropertyInspector(action: CodexDialAction): void { + const prior = this.dialInspectors.get(action.id); + if (prior) prior.generation = -Math.abs(prior.generation); + const registration: DialInspectorRegistration = { + action, + generation: ++this.nextDialInspectorGeneration + }; + this.dialInspectors.set(action.id, registration); + void this.sendModelCatalogToInspector(registration, 0, true); + } + + unregisterDialPropertyInspector(action: ActionIdentity): void { + const registration = this.dialInspectors.get(action.id); + if (!registration || registration.action !== action) return; + registration.generation = -Math.abs(registration.generation); + this.dialInspectors.delete(action.id); + } + + handleDialPropertyInspectorMessage(action: CodexDialAction, input: unknown): void { + const requestGeneration = this.readModelCatalogRequest(input); + if (requestGeneration == null) return; + const registration = this.dialInspectors.get(action.id); + if (!registration || registration.action !== action || registration.generation <= 0) return; + void this.sendModelCatalogToInspector(registration, requestGeneration, true); + } + + private readModelCatalogRequest(input: unknown): number | undefined { + try { + if (typeof input !== "object" || input === null || Array.isArray(input) || Object.getPrototypeOf(input) !== Object.prototype) { + return undefined; + } + const descriptors = Object.getOwnPropertyDescriptors(input); + if (Object.getOwnPropertySymbols(input).length !== 0 || Object.keys(descriptors).sort().join(",") !== "kind,requestGeneration") { + return undefined; + } + const kind = descriptors.kind; + const generation = descriptors.requestGeneration; + if (!kind || !generation || !("value" in kind) || !("value" in generation) || kind.value !== "request-model-catalog" || + !Number.isSafeInteger(generation.value) || generation.value < 0) return undefined; + return generation.value as number; + } catch { return undefined; } + } + + private currentModelCatalogResponse(requestGeneration: number): Omit { + const target = this.targetSnapshot(); + const isLocal = this.localHost != null && this.targetPlatform === this.localHost.platform; + const health = isLocal ? this.localHealth : this.relayClient?.currentHealth(); + const hostSnapshot = isLocal ? this.localSnapshot : this.relayClient?.currentSnapshot(); + const host = hostSnapshot?.host; + if (health?.state !== "ready" || !target || !host || host.hostId !== this.targetHostId || + target.activeModelId == null || target.activeModelDisplayName == null || + target.reasoningEffort == null || target.modelCatalog == null) { + return { kind: "model-catalog", requestGeneration, available: false }; + } + return { + kind: "model-catalog", + requestGeneration, + available: true, + hostId: host.hostId, + platform: host.platform, + snapshotGeneration: isLocal ? this.localSnapshotGeneration : hostSnapshot.observedAt, + activeModelId: target.activeModelId, + activeModelDisplayName: target.activeModelDisplayName, + reasoningEffort: target.reasoningEffort, + modelCatalog: target.modelCatalog.map((entry) => ({ + modelId: entry.modelId, + displayName: entry.displayName, + supportedReasoningEfforts: [...entry.supportedReasoningEfforts] + })) + }; + } + + private async sendModelCatalogToInspector( + registration: DialInspectorRegistration, + requestGeneration: number, + force: boolean + ): Promise { + if (this.dialInspectors.get(registration.action.id) !== registration || registration.generation <= 0) return; + const base = this.currentModelCatalogResponse(requestGeneration); + const signature = JSON.stringify({ ...base, requestGeneration: 0 }); + if (!force && signature === registration.lastSignature) return; + registration.lastSignature = signature; + const payload: ModelCatalogResponse = { ...base, catalogRevision: ++this.catalogRevision }; + try { + if (registration.action.sendToPropertyInspector) { + await registration.action.sendToPropertyInspector(payload as unknown as JsonValue); + } else { + const visible = streamDeck.ui.action; + if (visible !== registration.action) return; + await streamDeck.ui.sendToPropertyInspector(payload as unknown as JsonValue); + } + } + catch (error) { + if (this.dialInspectors.get(registration.action.id) === registration && registration.lastSignature === signature) { + registration.lastSignature = undefined; + } + streamDeck.logger.warn(`Codex Dial property inspector update failed: ${String(error)}`); + } + } + + private async pushModelCatalogs(): Promise { + await Promise.all([...this.dialInspectors.values()].map((registration) => + this.sendModelCatalogToInspector(registration, 0, false))); + } + + private disposeDialRegistration( + registration: DialRegistration, + releaseActiveMomentary = true + ): void { + if (registration.disposed) return; + registration.disposed = true; + registration.generation = -Math.abs(registration.generation); + registration.renderAgain = false; + this.clearDialNotice(registration); + this.resetHolds.delete(registration.action.id); + if (this.dials.get(registration.action.id) === registration) { + this.dials.delete(registration.action.id); + } + for (const reservation of [...registration.modelReasoningReservations]) { + this.cancelModelReasoningMutation(reservation); + } + const pressed = registration.pressed; + registration.pressed = undefined; + if (!pressed) return; + if (pressed.phase === "active" && pressed.lifecycle === "momentary" && releaseActiveMomentary) { + const release = () => this.releaseDialGesture(registration, pressed, false); + if (!registration.queue.enqueue(release)) registration.queue.enqueueCleanup(release); + } else { + pressed.phase = "canceled"; + } + } + + private isCurrentDialRegistration(registration: DialRegistration): boolean { + return !registration.disposed && registration.generation > 0 && + this.dials.get(registration.action.id) === registration; + } + + private serializeModelReasoningMutation( + operation: () => Promise, + reserved?: ModelReasoningMutationReservation + ): Promise { + const reservation = reserved ?? this.reserveModelReasoningMutations(1)?.[0]; + if (!reservation) { + return Promise.reject(new Error("Model and reasoning mutation queue is full.")); + } + if (reservation.released || reservation.canceled || reservation.claimed) { + this.cancelModelReasoningMutation(reservation); + return Promise.reject(new Error("Model and reasoning mutation was canceled or superseded.")); + } + reservation.claimed = true; + const lifecycleGeneration = this.controllerLifecycleGeneration; + const guardedOperation = async (): Promise => { + if (reservation.released || reservation.canceled) { + throw new Error("Model and reasoning mutation was canceled or superseded."); + } + reservation.running = true; + this.assertControllerLifecycle(lifecycleGeneration); + const result = await operation(); + this.assertControllerLifecycle(lifecycleGeneration); + return result; + }; + const ordered = reservation.predecessor.then(guardedOperation, guardedOperation); + const cancelable = new Promise((resolve, reject) => { + let settled = false; + const finish = (complete: () => void): void => { + if (settled) return; + settled = true; + this.modelReasoningLifecycleCancellations.delete(onCancel); + complete(); + }; + const onCancel = (): void => finish(() => reject( + new Error("Codex controller lifecycle was stopped or superseded."))); + this.modelReasoningLifecycleCancellations.add(onCancel); + void ordered.then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)) + ); + if (this.stopped || lifecycleGeneration !== this.controllerLifecycleGeneration) onCancel(); + }); + const result = cancelable.finally(() => this.completeModelReasoningMutation(reservation)); + return result; + } + + private reserveModelReasoningMutations( + count: number, + owner?: Set + ): ModelReasoningMutationReservation[] | undefined { + if (!Number.isSafeInteger(count) || count < 0 || + this.modelReasoningMutationPending + count > MAX_DIAL_QUEUE_PENDING) { + return undefined; + } + this.modelReasoningMutationPending += count; + const reservations: ModelReasoningMutationReservation[] = []; + for (let index = 0; index < count; index += 1) { + const predecessor = this.modelReasoningMutationTail; + let completeTurn!: () => void; + const turn = new Promise((resolve) => { completeTurn = resolve; }); + const reservation: ModelReasoningMutationReservation = { + released: false, + canceled: false, + claimed: false, + running: false, + predecessor, + completeTurn, + ...(owner ? { owner } : {}) + }; + owner?.add(reservation); + reservations.push(reservation); + this.modelReasoningMutationTail = predecessor.then(() => turn, () => turn); + } + return reservations; + } + + private cancelModelReasoningLifecycle(): void { + for (const cancel of [...this.modelReasoningLifecycleCancellations]) cancel(); + } + + private cancelModelReasoningMutation(reservation: ModelReasoningMutationReservation): void { + if (reservation.released) return; + reservation.canceled = true; + if (!reservation.running) this.completeModelReasoningMutation(reservation); + } + + private completeModelReasoningMutation(reservation: ModelReasoningMutationReservation): void { + if (reservation.released) return; + reservation.released = true; + reservation.owner?.delete(reservation); + this.modelReasoningMutationPending -= 1; + reservation.completeTurn(); + } + + private reserveDialGestureMutation( + registration: DialRegistration, + gesture: DialGesture + ): boolean { + if (gesture.binding !== "reasoning.decrease" && gesture.binding !== "reasoning.increase" && + gesture.binding !== "keycap.MIND-" && gesture.binding !== "keycap.MIND+") { + return true; + } + const reservation = this.reserveModelReasoningMutations( + 1, registration.modelReasoningReservations)?.[0]; + if (!reservation) return false; + gesture.modelReasoningReservation = reservation; + return true; + } + + private releaseDialGestureMutation(gesture: DialGesture): void { + if (!gesture.modelReasoningReservation) return; + this.cancelModelReasoningMutation(gesture.modelReasoningReservation); + gesture.modelReasoningReservation = undefined; + } + + private takeDialGestureMutation( + gesture: DialGesture + ): ModelReasoningMutationReservation | undefined { + const reservation = gesture.modelReasoningReservation; + gesture.modelReasoningReservation = undefined; + return reservation; + } + + private assertControllerLifecycle(lifecycleGeneration: number): void { + if (this.stopped || lifecycleGeneration !== this.controllerLifecycleGeneration) { + throw new Error("Codex controller lifecycle was stopped or superseded."); + } + } + + private adjustLocalReasoningFromRelay( + direction: ReasoningAdjustment, + policy?: ReasoningAdjustmentPolicy + ): Promise { + return this.serializeModelReasoningMutation( + () => this.adjustLocalReasoning(direction, policy)); + } + + private applyLocalModelPresetFromRelay( + request: ModelPresetRequest + ): Promise { + const localHost = this.localHost; + if (!localHost) return Promise.reject(new Error("The local Codex host is unavailable.")); + return this.serializeModelReasoningMutation(() => this.sendModelPresetToHost({ + kind: "host", hostId: localHost.hostId, platform: localHost.platform + }, request)); + } + + private async adjustLocalReasoning( + direction: ReasoningAdjustment, + policy?: ReasoningAdjustmentPolicy + ): Promise { + const lifecycleGeneration = this.controllerLifecycleGeneration; + const operationHost = this.localHost; + const mutationGeneration = ++this.localSnapshotGeneration; + const rawExecution = await this.microBridge.adjustReasoning(direction, policy); + this.assertControllerLifecycle(lifecycleGeneration); + const resultObservationFloor = this.localSnapshotGeneration; + let execution = parseReasoningExecution(rawExecution); + if (!execution) { + if (!isAppliedReasoningExecutionWithUnconfirmedEffort(rawExecution)) { + throw new Error("Codex returned an invalid reasoning adjustment result."); + } + execution = await this.reconcileUnconfirmedLocalReasoning(lifecycleGeneration); + } else if (execution.outcome === "applied" && execution.reasoningEffort === undefined) { + execution = await this.reconcileUnconfirmedLocalReasoning(lifecycleGeneration); + } + if (execution.outcome === "applied" && this.localSnapshotGeneration !== mutationGeneration) { + return this.reconcileLocalReasoningAfterResult( + lifecycleGeneration, operationHost, resultObservationFloor); + } + const current = this.localSnapshot; + if (execution.reasoningEffort !== undefined && operationHost != null && + this.localSnapshotGeneration === mutationGeneration && + this.localHost?.hostId === operationHost.hostId && + this.localHost.platform === operationHost.platform && current != null && + current.host.hostId === operationHost.hostId && current.host.platform === operationHost.platform && + current.snapshot.reasoningEffort !== execution.reasoningEffort) { + this.localSnapshot = { + ...current, + snapshot: { ...current.snapshot, reasoningEffort: execution.reasoningEffort } + }; + this.committedLocalSnapshotGeneration = mutationGeneration; + } + return execution; + } + + private authoritativeLocalReasoningEffort(operationHost?: CodexHost): string | undefined { + const current = this.localSnapshot; + const effort = current?.snapshot.reasoningEffort; + if (this.committedLocalSnapshotGeneration !== this.localSnapshotGeneration || + this.localHealth.state !== "ready" || operationHost == null || current == null || + this.localHost?.hostId !== operationHost.hostId || + this.localHost.platform !== operationHost.platform || + current.host.hostId !== operationHost.hostId || + current.host.platform !== operationHost.platform || + !isSafeReasoningIdentifier(effort)) { + return undefined; + } + return effort; + } + + private async reconcileLocalReasoningAfterResult( + lifecycleGeneration: number, + operationHost: CodexHost | undefined, + resultObservationFloor: number + ): Promise { + const pendingRefresh = this.refreshInFlight; + if (pendingRefresh) { + try { await pendingRefresh; } + catch { /* Authority and health are checked below. */ } + if (this.refreshInFlight === pendingRefresh) this.refreshInFlight = undefined; + this.assertControllerLifecycle(lifecycleGeneration); + } + let authoritativeEffort = this.authoritativeLocalReasoningEffort(operationHost); + if (this.committedLocalSnapshotGeneration > resultObservationFloor && + authoritativeEffort !== undefined) { + return { outcome: "applied", reasoningEffort: authoritativeEffort }; + } + try { await this.refresh(); } + catch { /* Authority and health are checked below. */ } + this.assertControllerLifecycle(lifecycleGeneration); + authoritativeEffort = this.authoritativeLocalReasoningEffort(operationHost); + if (this.committedLocalSnapshotGeneration > resultObservationFloor && + authoritativeEffort !== undefined) { + return { outcome: "applied", reasoningEffort: authoritativeEffort }; + } + this.localHealth = { + state: "degraded", + reason: "local-bridge-unavailable", + changedAt: Date.now() + }; + throw new Error("Authoritative reasoning state is unavailable after the newer refresh."); + } + + private async reconcileUnconfirmedLocalReasoning( + lifecycleGeneration = this.controllerLifecycleGeneration + ): Promise { + if (this.refreshInFlight) { + try { await this.refreshInFlight; } + catch { /* A fresh refresh is still attempted below. */ } + } + this.assertControllerLifecycle(lifecycleGeneration); + const priorSnapshot = this.localSnapshot; + let refreshCompleted = false; + try { + await this.refresh(); + this.assertControllerLifecycle(lifecycleGeneration); + refreshCompleted = true; + } catch { /* Authority is checked below so overridden/test refresh paths fail closed identically. */ } + const localHost = this.localHost; + const current = this.localSnapshot; + const reasoningEffort = current?.snapshot.reasoningEffort; + if (refreshCompleted && current !== priorSnapshot && this.localHealth.state === "ready" && + localHost != null && current != null && + current.host.hostId === localHost.hostId && current.host.platform === localHost.platform && + isSafeReasoningIdentifier(reasoningEffort)) { + return { outcome: "applied", reasoningEffort }; + } + this.localHealth = { + state: "degraded", + reason: "local-bridge-unavailable", + changedAt: Date.now() + }; + throw new Error("Authoritative reasoning state is unavailable after the applied adjustment."); + } + + rotateDial(action: CodexDialAction, ticks: number): void { + const registration = this.dials.get(action.id); + if (!registration) return; + if (!isDialTickCount(ticks)) { + void this.reportDialCommandError( + registration, + new Error("Dial rotation ignored: invalid or excessive tick count.") + ); + return; + } + registration.settings = normalizeDialSettings(registration.settings); + if (registration.settings.rotation.kind === "model-presets") { + if (ticks === 0) return; + const count = Math.abs(ticks); + if (!registration.queue.canEnqueue(count)) { + void this.reportDialCommandError( + registration, + new Error("Dial rotation ignored: command queue is full.") + ); + return; + } + const reservations = this.reserveModelReasoningMutations( + count, registration.modelReasoningReservations); + if (!reservations) { + void this.reportDialCommandError( + registration, + new Error("Dial rotation ignored: model and reasoning mutation queue is full.") + ); + return; + } + const direction: ModelPresetDirection = ticks > 0 ? "clockwise" : "counter-clockwise"; + for (let detent = 0; detent < count; detent += 1) { + this.enqueueModelPresetDirection(registration, direction, reservations[detent]!); + } + return; + } + const reduced = reduceDialRotation( + registration.settings, + registration.state, + this.dialRuntimeView(registration.settings, registration.state), + ticks + ); + registration.state = reduced.state; + if (registration.settings.rotation.kind === "selector") { + void this.renderDialSafely(registration); + } + if (!registration.queue.canEnqueue(reduced.bindings.length)) { + void this.reportDialCommandError( + registration, + new Error("Dial rotation ignored: command queue is full.") + ); + return; + } + const reasoningCount = reduced.bindings.filter((binding) => + binding === "reasoning.decrease" || binding === "reasoning.increase" || + binding === "keycap.MIND-" || binding === "keycap.MIND+").length; + const reasoningReservations = this.reserveModelReasoningMutations( + reasoningCount, registration.modelReasoningReservations); + if (!reasoningReservations) { + void this.reportDialCommandError( + registration, + new Error("Dial rotation ignored: model and reasoning mutation queue is full.") + ); + return; + } + const startedAt = Date.now(); + let reasoningReservationIndex = 0; + for (const binding of reduced.bindings) { + const gesture = this.captureDialGesture(registration, binding, undefined, startedAt); + if (binding === "reasoning.decrease" || binding === "reasoning.increase" || + binding === "keycap.MIND-" || binding === "keycap.MIND+") { + gesture.modelReasoningReservation = reasoningReservations[reasoningReservationIndex++]; + } + this.enqueueDialTap(registration, gesture); + } + } + + private enqueueModelPresetDirection( + registration: DialRegistration, + direction: ModelPresetDirection, + reservation: ModelReasoningMutationReservation + ): void { + const accepted = registration.queue.enqueue(async () => { + await this.serializeModelReasoningMutation(async () => { + if (!this.isCurrentDialRegistration(registration)) return; + registration.settings = normalizeDialSettings(registration.settings); + if (registration.settings.rotation.kind !== "model-presets") return; + const resolution = resolveModelPresetDirection( + registration.settings, + this.dialRuntimeView(registration.settings, registration.state), + direction + ); + if (resolution.kind !== "target") { + await this.renderDialSafely(registration); + return; + } + const route: DialHostRoute = { + kind: "host", + hostId: this.targetHostId, + platform: this.targetPlatform + }; + const request: ModelPresetRequest = { + modelId: resolution.entry.modelId, + reasoningEffort: resolution.entry.reasoningEffort, + includeUltra: registration.settings.includeUltraReasoning + }; + registration.state = { ...registration.state, modelPresetSwitching: true }; + await this.renderDialSafely(registration); + try { + await this.sendModelPresetToHost(route, request); + if (!this.isCurrentDialRegistration(registration)) return; + registration.state = { ...registration.state, modelPresetSwitching: false }; + await this.renderDialSafely(registration); + } catch (error) { + if (!this.isCurrentDialRegistration(registration)) return; + registration.state = { ...registration.state, modelPresetSwitching: false }; + await this.renderDialSafely(registration); + await this.reportDialCommandError(registration, error); + } + }, reservation); + }); + if (!accepted) { + this.cancelModelReasoningMutation(reservation); + } + if (!accepted && this.isCurrentDialRegistration(registration)) { + void this.reportDialCommandError( + registration, + new Error("Dial rotation ignored: command queue is full.") + ); + } + } + + beginDialPress(action: CodexDialAction): Promise { + const registration = this.dials.get(action.id); + if (!registration) return Promise.resolve(); + if (registration.pressed) return registration.queue.idle(); + registration.settings = normalizeDialSettings(registration.settings); + const pressed = this.resolveDialPress(registration, Date.now()); + if (!this.reserveDialGestureMutation(registration, pressed)) { + void this.reportDialCommandError( + registration, + new Error("Dial press ignored: model and reasoning mutation queue is full.") + ); + return registration.queue.idle(); + } + registration.pressed = pressed; + this.enqueueDialDown(registration, pressed); + return registration.queue.idle(); + } + + finishDialPress(action: CodexDialAction): Promise { + const registration = this.dials.get(action.id); + if (!registration) return Promise.resolve(); + const pressed = registration.pressed; + registration.pressed = undefined; + if (!pressed) return registration.queue.idle(); + pressed.endedAt = Date.now(); + this.enqueueDialUp(registration, pressed); + return registration.queue.idle(); + } + + touchDial(action: CodexDialAction): Promise { + const registration = this.dials.get(action.id); + if (!registration) return Promise.resolve(); + registration.settings = normalizeDialSettings(registration.settings); + const gesture = this.captureDialGesture( + registration, + registration.settings.touchTap, + undefined, + Date.now() + ); + if (!this.reserveDialGestureMutation(registration, gesture)) { + void this.reportDialCommandError( + registration, + new Error("Dial gesture ignored: model and reasoning mutation queue is full.") + ); + return registration.queue.idle(); + } + this.enqueueDialTap(registration, gesture); + return registration.queue.idle(); + } + registerUsageLimit(action: KeyAction, mode: UsageLimitMode): void { const registration = { action, mode }; this.usageLimitActions.set(action.id, registration); @@ -252,23 +1035,62 @@ export class DeckController { this.unregister(action, this.rateLimitResetActions); } - beginRateLimitReset(action: ActionIdentity): void { - this.resetHolds.set(action.id, Date.now()); + beginRateLimitReset( + action: ActionIdentity, + startedAt = Date.now(), + sourceHostId?: string + ): void { + this.resetHolds.set(action.id, { + startedAt, + ...(sourceHostId == null ? {} : { sourceHostId }) + }); const registered = this.rateLimitResetActions.get(action.id); - if (registered) void this.renderRateLimitReset(registered); + if (registered) { + void this.renderRateLimitReset(registered).catch((error) => + streamDeck.logger.error(`Rate-limit reset hold render failed (${action.id}): ${String(error)}`)); + } } - async finishRateLimitReset(action: ActionIdentity): Promise { - const startedAt = this.resetHolds.get(action.id); + async finishRateLimitReset( + action: ActionIdentity, + endedAt = Date.now(), + sourceHostId?: string, + requireReady = false + ): Promise { + const hold = this.resetHolds.get(action.id); this.resetHolds.delete(action.id); const registered = this.rateLimitResetActions.get(action.id); if (registered) await this.renderRateLimitReset(registered); - if (startedAt == null || Date.now() - startedAt < RESET_HOLD_MS) return false; - const source = this.accountUsageSource(); + if (hold == null || endedAt - hold.startedAt < RESET_HOLD_MS) return false; + const capturedHostId = sourceHostId ?? hold.sourceHostId; + const source = capturedHostId == null + ? this.accountUsageSource() + : this.accountUsageSourceForHost(capturedHostId); + if (requireReady && source.health.state !== "ready") { + throw new Error("The captured Codex usage host is not ready."); + } const usage = source.snapshot?.usage; - if ((usage?.resetCreditsAvailable ?? 0) <= 0) throw new Error("No rate-limit reset credit is available."); - if (usage?.resetCreditsApplicable === 0) throw new Error("No rate-limit reset credit is currently applicable."); - await this.sendToHost(source.hostId, { kind: "rate-limit-reset" }, () => this.microBridge.consumeRateLimitReset()); + const available = usage?.resetCreditsAvailable; + if (typeof available !== "number" || !Number.isSafeInteger(available) || available <= 0) { + throw new Error("No rate-limit reset credit is available."); + } + const applicable = usage?.resetCreditsApplicable; + if (typeof applicable !== "number" || !Number.isSafeInteger(applicable) || applicable <= 0) { + throw new Error("No rate-limit reset credit is currently applicable."); + } + if (capturedHostId == null) { + await this.sendToHost( + source.hostId, + { kind: "rate-limit-reset" }, + () => this.microBridge.consumeRateLimitReset() + ); + } else { + await this.sendDialToHost( + this.hostRoute(capturedHostId), + { kind: "rate-limit-reset" }, + () => this.microBridge.consumeRateLimitReset() + ); + } await this.refresh(); return true; } @@ -288,21 +1110,74 @@ export class DeckController { await this.renderAll(); } - async sendAgent(slot: number, act: 0 | 1): Promise { - const assignment = act === 0 ? this.pressedAgents.get(slot) : this.routedSlots[slot]; - if (!assignment) throw new Error(`No Codex task is assigned to global agent slot ${slot + 1}.`); - if (act === 1) this.pressedAgents.set(slot, assignment); - else this.pressedAgents.delete(slot); + async sendAgent(slot: number, act: 0 | 1, expectedThreadKey?: string): Promise { + if (act === 1) { + const current = this.routedSlots[slot]; + if (!current) throw new Error(`No Codex task is assigned to global agent slot ${slot + 1}.`); + const threadKey = current.threadKey; + if (!threadKey) throw new Error("The selected Codex task has no stable thread identity."); + if (expectedThreadKey != null && threadKey !== expectedThreadKey) { + throw new Error("The selected Codex task no longer matches the highlighted task."); + } + this.failedAgentUps.delete(slot); + const assignment = { ...current, host: { ...current.host } }; + const outcome = this.sendAgentAssignment(assignment, 1).then( + () => ({ ok: true }), + (error: unknown) => ({ ok: false, error }) + ); + const gesture = { assignment, outcome }; + this.pressedAgents.set(slot, gesture); + const result = await outcome; + if (!result.ok) { + if (this.pressedAgents.get(slot) === gesture) { + this.pressedAgents.delete(slot); + this.failedAgentUps.set(slot, threadKey); + } + throw result.error; + } + return; + } + + const gesture = this.pressedAgents.get(slot); + if (!gesture) { + const failedThreadKey = this.failedAgentUps.get(slot); + if (failedThreadKey == null) { + throw new Error(`No Codex task is assigned to global agent slot ${slot + 1}.`); + } + if (expectedThreadKey != null && failedThreadKey !== expectedThreadKey) { + throw new Error("The selected Codex task no longer matches the highlighted task."); + } + this.failedAgentUps.delete(slot); + return; + } + if (expectedThreadKey != null && gesture.assignment.threadKey !== expectedThreadKey) { + throw new Error("The selected Codex task no longer matches the highlighted task."); + } + this.pressedAgents.delete(slot); + const result = await gesture.outcome; + if (!result.ok) return; + await this.sendAgentAssignment(gesture.assignment, 0); + void this.refresh(); + } + + private async sendAgentAssignment(assignment: RoutedAgentSlot, act: 0 | 1): Promise { if (!assignment.threadKey) throw new Error("The selected Codex task has no stable thread identity."); if (assignment.host.hostId === this.localHost?.hostId) { await this.microBridge.sendAgent(assignment.sourceSlot, act, assignment.threadKey); - } else await this.sendRemote({ kind: "agent", slot: assignment.sourceSlot, threadKey: assignment.threadKey, act }); - if (act === 0) void this.refresh(); + return; + } + const remote = this.relayClient?.currentHost(); + if (remote?.hostId !== assignment.host.hostId) { + throw new Error("The captured Codex agent host is no longer connected."); + } + await this.sendRemote({ + kind: "agent", slot: assignment.sourceSlot, threadKey: assignment.threadKey, act + }); } async sendMicroAction(slot: MicroActionSlot, act: 0 | 1): Promise { const target = this.pressTarget(`action:${slot}`, act); - await this.sendToHost(target, { kind: "action", slot, act }, () => this.microBridge.sendAction(slot, act)); + await this.sendToHost(target, { kind: "action", slot, act }, () => this.runLocalMicroAction(slot, act)); } async sendJoystick(direction: MicroDirection, distance: 0 | 1): Promise { @@ -316,11 +1191,16 @@ export class DeckController { } async adjustReasoning(direction: ReasoningAdjustment): Promise { - await this.sendToTarget({ kind: "reasoning", direction }, () => this.microBridge.adjustReasoning(direction)); + await this.serializeModelReasoningMutation(() => + this.sendToTarget({ kind: "reasoning", direction, includeUltra: true }, async () => { + await this.adjustLocalReasoning(direction, { includeUltra: true }); + })); } async runKeycap(keycapId: OfficialKeycapId): Promise { - await this.sendToTarget({ kind: "keycap", keycapId }, () => this.microBridge.runKeycap(keycapId)); + if (keycapId === "MIND+") return this.adjustReasoning("increase"); + if (keycapId === "MIND-") return this.adjustReasoning("decrease"); + await this.sendToTarget({ kind: "keycap", keycapId }, () => this.runLocalKeycap(keycapId)); } async createTask(): Promise { @@ -328,6 +1208,64 @@ export class DeckController { else await openCodexThread("new"); } + async refreshUsage(): Promise { + const source = this.accountUsageSource(); + if (source.hostId != null && source.hostId !== this.localHost?.hostId) { + const remote = this.relayClient?.currentHost(); + if (remote?.hostId !== source.hostId || + !this.relayClient?.supportsCapabilityForSnapshot( + "usage-refresh", source.hostId, remote.platform + )) { + throw new Error("Remote Codex host does not support usage refresh."); + } + await this.relayClient.send({ kind: "usage-refresh" }); + return; + } + + await this.refreshLocalUsage(); + } + + private refreshLocalUsage(): Promise { + const pending = this.refreshLocalUsageOnce(); + const tracked = pending.then(() => undefined, () => undefined); + this.refreshInFlight = tracked; + return pending.finally(() => { + if (this.refreshInFlight === tracked) this.refreshInFlight = undefined; + }); + } + + private async refreshLocalUsageOnce(): Promise { + const generation = ++this.localSnapshotGeneration; + let snapshot: MicroSnapshot; + try { + const host = this.localHost ?? await getOrCreateHostIdentity(); + snapshot = await this.microBridge.requestUsageRefresh(); + if (generation !== this.localSnapshotGeneration) { + throw new Error("Codex usage refresh was superseded by a newer refresh."); + } + const observedAt = Date.now(); + this.localHost = host; + this.mobileRelayServer?.updateHost(host); + this.localMobileRelayServer?.updateHost(host); + this.localSnapshot = { host, snapshot, observedAt }; + this.committedLocalSnapshotGeneration = generation; + this.localHealth = { state: "ready", changedAt: observedAt }; + this.lastError = ""; + } catch (error) { + if (generation !== this.localSnapshotGeneration) throw error; + this.localHealth = { state: "degraded", reason: "local-bridge-unavailable", changedAt: Date.now() }; + const message = String(error); + if (message !== this.lastError) { + this.lastError = message; + streamDeck.logger.warn(`Codex usage refresh unavailable: ${message}`); + } + await this.refreshDisplay(); + throw error; + } + await this.refreshDisplay(); + return snapshot; + } + private async refresh(): Promise { if (this.refreshInFlight) return this.refreshInFlight; const pending = this.refreshOnce(); @@ -337,15 +1275,21 @@ export class DeckController { } private async refreshOnce(): Promise { + const generation = ++this.localSnapshotGeneration; try { const snapshot = await this.microBridge.refresh(); - this.localHost = await getOrCreateHostIdentity(); - this.mobileRelayServer?.updateHost(this.localHost); - this.localMobileRelayServer?.updateHost(this.localHost); - this.localSnapshot = { host: this.localHost, snapshot, observedAt: Date.now() }; + if (generation !== this.localSnapshotGeneration) return; + const host = await getOrCreateHostIdentity(); + if (generation !== this.localSnapshotGeneration) return; + this.localHost = host; + this.mobileRelayServer?.updateHost(host); + this.localMobileRelayServer?.updateHost(host); + this.localSnapshot = { host, snapshot, observedAt: Date.now() }; + this.committedLocalSnapshotGeneration = generation; this.localHealth = { state: "ready", changedAt: Date.now() }; this.lastError = ""; } catch (error) { + if (generation !== this.localSnapshotGeneration) return; this.localHealth = { state: "degraded", reason: "local-bridge-unavailable", changedAt: Date.now() }; const message = String(error); if (message !== this.lastError) { @@ -403,18 +1347,828 @@ export class DeckController { await this.renderAll(); } + private async refreshDisplayAfterRelaySnapshot(): Promise { + try { await this.refreshDisplay(); } + catch (error) { + streamDeck.logger.error(`Remote Codex snapshot display failed: ${String(error)}`); + } + } + private async renderAll(): Promise { await Promise.all([ + this.pushModelCatalogs(), ...[...this.agents.values()].map((registration) => this.renderAgent(registration)), ...[...this.microActions.values()].map((registration) => this.renderMicroAction(registration)), ...[...this.fixedActions.values()].map((registration) => this.renderFixedAction(registration)), ...[...this.hostToggleActions.values()].map((action) => this.renderHostToggle(action)), ...[...this.usageLimitActions.values()].map((registration) => this.renderUsageLimit(registration)), ...[...this.usageOverviewActions.values()].map((action) => this.renderUsageOverview(action)), - ...[...this.rateLimitResetActions.values()].map((action) => this.renderRateLimitReset(action)) + ...[...this.rateLimitResetActions.values()].map((action) => this.renderRateLimitReset(action)), + ...[...this.dials.values()].map((registration) => this.renderDialSafely(registration)) ]); } + private dialRuntimeView( + settings: CodexDialSettings, + state: DialRuntimeState + ): DialRuntimeView { + const targetSnapshot = this.targetSnapshot(); + const usageSource = this.accountUsageSource(); + const usage = usageSource.snapshot?.usage; + const selectedUsage = selectUsageWindow(usage, state.usageMode); + const fiveHour = selectUsageWindow(usage, "five-hour"); + const weekly = selectUsageWindow(usage, "weekly"); + const feedbackUsesUsage = settings.feedback === "usage" || + (settings.feedback === "auto" && settings.rotation.kind === "selector" && + settings.rotation.source === "usage"); + const actionLabels: DialRuntimeView["actionLabels"] = {}; + const occupiedHostIds = new Set( + this.routedSlots + .filter((slot) => slot.threadKey != null) + .map((slot) => slot.host.hostId) + ); + const showHostBadges = occupiedHostIds.size > 1; + for (const [slot, value] of Object.entries(targetSnapshot?.layout.slots ?? {})) { + const keycapId = value.keycapId; + const label = ADDITIONAL_KEYCAPS.find(({ id }) => id === keycapId)?.name ?? keycapId; + const binding = `micro.${slot}`; + if (isDialBindingId(binding, "selector")) actionLabels[binding] = label; + } + for (const keycap of ADDITIONAL_KEYCAPS) actionLabels[`keycap.${keycap.id}`] = keycap.name; + return { + health: (feedbackUsesUsage ? usageSource.health : this.targetHealth()).state, + reasoningEffort: targetSnapshot?.reasoningEffort, + activeModelId: targetSnapshot?.activeModelId, + activeModelDisplayName: targetSnapshot?.activeModelDisplayName, + ...(targetSnapshot?.modelCatalog == null ? {} : { + modelCatalog: targetSnapshot.modelCatalog.map((entry) => ({ + modelId: entry.modelId, + displayName: entry.displayName, + supportedReasoningEfforts: [...entry.supportedReasoningEfforts] + })) + }), + agents: this.routedSlots + .filter((slot): slot is RoutedAgentSlot & { threadKey: string } => slot.threadKey != null) + .map((slot) => ({ + id: slot.id, + identity: `${slot.host.hostId}:${slot.threadKey}`, + threadKey: slot.threadKey, + title: slot.title ?? `Agent ${slot.id + 1}`, + status: slot.status, + health: this.healthForHost(slot.host).state, + ...(showHostBadges + ? { hostBadge: slot.host.platform === "darwin" ? "M" as const : "W" as const } + : {}), + ...(slot.contextUsedPercent == null ? {} : { contextUsedPercent: slot.contextUsedPercent }) + })), + actionLabels, + ...(usage == null ? {} : { + usage: { + mode: selectedUsage?.kind === "five-hour" || selectedUsage?.kind === "weekly" + ? selectedUsage.kind + : "auto", + remainingPercent: selectedUsage?.remainingPercent, + resetsAt: selectedUsage?.resetsAt, + observedAt: usage.observedAt, + fiveHourRemaining: fiveHour?.remainingPercent, + weeklyRemaining: weekly?.remainingPercent + } + }), + now: Date.now() + }; + } + + private async renderDial(registration: DialRegistration): Promise { + if (!this.isCurrentDialRegistration(registration) || registration.noticeActive) return; + registration.settings = normalizeDialSettings(registration.settings); + const view = this.dialRuntimeView(registration.settings, registration.state); + if (registration.settings.rotation.kind === "selector") { + registration.state = reconcileSelector( + registration.state, + selectorItems(registration.settings, view) + ); + } + const feedback = deriveDialFeedback(registration.settings, registration.state, view); + const signature = JSON.stringify(feedback); + if (registration.lastFeedback === signature) return; + if (!this.isCurrentDialRegistration(registration)) return; + await registration.action.setFeedback({ + title: feedback.title, + value: feedback.value, + detail: feedback.detail, + indicator: feedback.indicator, + accent: { value: 100, bar_fill_c: feedback.accent } + }); + if (this.isCurrentDialRegistration(registration)) registration.lastFeedback = signature; + } + + private async renderDialSafely(registration: DialRegistration): Promise { + if (!this.isCurrentDialRegistration(registration)) return; + if (registration.rendering) { + registration.renderAgain = true; + await registration.rendering; + return; + } + const rendering = (async () => { + do { + registration.renderAgain = false; + try { + await this.renderDial(registration); + } catch (error) { + const message = String(error); + if (rememberDialError(this.dialRenderErrors, message)) { + streamDeck.logger.error(`Codex dial feedback unavailable: ${message}`); + } + } + } while (registration.renderAgain && this.isCurrentDialRegistration(registration)); + })(); + registration.rendering = rendering; + try { await rendering; } + finally { + if (registration.rendering === rendering) registration.rendering = undefined; + if (registration.disposed) { + const current = this.dials.get(registration.action.id); + if (current && current !== registration) void this.renderDialSafely(current); + } + } + } + + private updateDialDescription(registration: DialRegistration): void { + if (!this.isCurrentDialRegistration(registration)) return; + const { settings, action } = registration; + const rotate = settings.rotation.kind === "paired" ? "Adjust" : "Select"; + const push = dialBindingLabel(settings.press); + const touch = dialBindingLabel(settings.touchTap); + void action.setTriggerDescription({ rotate, push, touch }).catch((error) => { + const message = String(error); + if (rememberDialError(this.dialDescriptionErrors, message)) { + streamDeck.logger.warn(`Codex dial trigger descriptions unavailable: ${message}`); + } + }); + } + + private clearDialNotice(registration: DialRegistration): void { + if (!registration.noticeActive && !registration.noticeTimer) return; + if (registration.noticeTimer) clearTimeout(registration.noticeTimer); + registration.noticeTimer = undefined; + registration.noticeActive = false; + registration.noticeRevision += 1; + registration.lastFeedback = undefined; + } + + private async restoreDialAfterStaleNotice(registration: DialRegistration): Promise { + const current = this.dials.get(registration.action.id); + if (!current || current === registration || !this.isCurrentDialRegistration(current)) return; + current.lastFeedback = undefined; + await this.renderDialSafely(current); + } + + private async showDialNotice( + registration: DialRegistration, + notice: DialNotice, + durationMs: number + ): Promise { + if (!this.isCurrentDialRegistration(registration)) return; + if (registration.noticeTimer) clearTimeout(registration.noticeTimer); + registration.noticeTimer = undefined; + const revision = ++registration.noticeRevision; + registration.noticeActive = true; + if (registration.rendering) await registration.rendering; + if (!this.isCurrentDialRegistration(registration)) { + await this.restoreDialAfterStaleNotice(registration); + return; + } + if (registration.noticeRevision !== revision) { + registration.lastFeedback = undefined; + await this.renderDialSafely(registration); + return; + } + try { + await registration.action.setFeedback(notice); + } catch (error) { + if (!this.isCurrentDialRegistration(registration)) { + await this.restoreDialAfterStaleNotice(registration); + return; + } + if (registration.noticeRevision !== revision) { + registration.lastFeedback = undefined; + await this.renderDialSafely(registration); + return; + } + registration.noticeActive = false; + const message = String(error); + if (rememberDialError(this.dialSuccessErrors, message)) { + streamDeck.logger.warn(`Codex dial temporary feedback unavailable: ${message}`); + } + registration.lastFeedback = undefined; + await this.renderDialSafely(registration); + return; + } + if (!this.isCurrentDialRegistration(registration)) { + await this.restoreDialAfterStaleNotice(registration); + return; + } + if (registration.noticeRevision !== revision) { + registration.lastFeedback = undefined; + await this.renderDialSafely(registration); + return; + } + registration.noticeTimer = setTimeout(() => { + if (!this.isCurrentDialRegistration(registration) || + registration.noticeRevision !== revision) return; + registration.noticeTimer = undefined; + registration.noticeActive = false; + registration.lastFeedback = undefined; + void this.renderDialSafely(registration); + }, durationMs); + } + + private async showDialSuccess(registration: DialRegistration): Promise { + await this.showDialNotice(registration, { + title: "RATE LIMIT", + value: "RESET COMPLETE", + detail: "CREDIT APPLIED", + indicator: 100, + accent: { value: 100, bar_fill_c: "#35D86B" } + }, DIAL_SUCCESS_MS); + } + + private async showDialUltraOff(registration: DialRegistration): Promise { + await this.showDialNotice(registration, { + title: "REASONING", + value: "ULTRA OFF", + detail: "ENABLE IN DIAL SETTINGS", + indicator: 100, + accent: { value: 100, bar_fill_c: "#FF9A3D" } + }, DIAL_ULTRA_NOTICE_MS); + } + + private resolveDialPress(registration: DialRegistration, startedAt: number): DialGesture { + const binding = registration.settings.press; + if (binding !== "selector.activate") { + return this.captureDialGesture(registration, binding, undefined, startedAt); + } + const item = selectedItem( + registration.settings, + registration.state, + this.dialRuntimeView(registration.settings, registration.state) + ); + return this.captureDialGesture(registration, binding, item, startedAt); + } + + private captureDialGesture( + registration: DialRegistration, + requestedBinding: DialBindingId, + item: DialSelectorItem | undefined, + startedAt: number + ): DialGesture { + let binding = requestedBinding; + let route: DialRoute; + if (binding === "selector.activate") { + if (item?.agentSlot != null && item.threadKey) { + const assignment = this.routedSlots[item.agentSlot]; + const identity = assignment?.threadKey + ? `${assignment.host.hostId}:${assignment.threadKey}` + : undefined; + route = assignment && identity === item.id + ? { + kind: "agent", + assignment: { + ...assignment, + host: { ...assignment.host } + }, + identity + } + : { kind: "invalid-agent" }; + } else if (item?.binding && isDialBindingId(item.binding, "selector")) { + binding = item.binding; + route = this.captureDialHostRoute(binding); + } else { + route = { kind: "invalid-agent" }; + } + } else { + route = this.captureDialHostRoute(binding); + } + return { + binding, + route, + includeUltraReasoning: registration.settings.includeUltraReasoning, + lifecycle: route.kind === "agent" ? "momentary" : bindingLifecycle(binding), + startedAt, + phase: "pending" + }; + } + + private captureDialHostRoute(binding: DialBindingId): DialHostRoute { + if (binding === "usage.refresh" || binding === "usage.rate-limit-reset") { + const source = this.accountUsageSource(); + return this.hostRoute(source.hostId); + } + return { + kind: "host", + hostId: this.targetHostId, + platform: this.targetPlatform + }; + } + + private enqueueDialDown(registration: DialRegistration, gesture: DialGesture): void { + const accepted = registration.queue.enqueue(async () => { + if (!this.isCurrentDialRegistration(registration)) { + gesture.phase = "canceled"; + this.releaseDialGestureMutation(gesture); + return; + } + try { + if (gesture.lifecycle === "hold") { + const sourceHostId = gesture.route.kind === "host" ? gesture.route.hostId : undefined; + this.beginRateLimitReset(registration.action, gesture.startedAt, sourceHostId); + gesture.phase = "active"; + return; + } + await this.dispatchDialGesture(registration, gesture, 1); + if (gesture.lifecycle !== "momentary") { + gesture.phase = "completed"; + return; + } + gesture.phase = "active"; + if (!this.isCurrentDialRegistration(registration)) { + await this.releaseDialGesture( + registration, + gesture, + this.isCurrentDialRegistration(registration) + ); + } + } catch (error) { + gesture.phase = "failed"; + if (this.isCurrentDialRegistration(registration)) { + await this.reportDialCommandError(registration, error); + } + } + }); + if (!accepted) { + gesture.phase = "failed"; + this.releaseDialGestureMutation(gesture); + if (this.isCurrentDialRegistration(registration)) { + void this.reportDialCommandError( + registration, + new Error("Dial press ignored: command queue is full.") + ); + } + } + } + + private enqueueDialUp(registration: DialRegistration, gesture: DialGesture): void { + const release = async (): Promise => { + if (gesture.phase !== "active") return; + if (gesture.lifecycle === "hold") { + gesture.phase = "releasing"; + let reset = false; + try { + const sourceHostId = gesture.route.kind === "host" ? gesture.route.hostId : undefined; + reset = await this.finishRateLimitReset( + registration.action, + gesture.endedAt ?? gesture.startedAt, + sourceHostId, + true + ); + gesture.phase = "released"; + } catch (error) { + gesture.phase = "released"; + if (this.isCurrentDialRegistration(registration)) { + await this.reportDialCommandError(registration, error); + } + } + if (reset && this.isCurrentDialRegistration(registration)) { + await this.showDialSuccess(registration); + } + return; + } + await this.releaseDialGesture( + registration, + gesture, + this.isCurrentDialRegistration(registration) + ); + }; + if (!registration.queue.enqueue(release)) { + if (gesture.lifecycle === "momentary") { + registration.queue.enqueueCleanup(release); + } else { + gesture.phase = "failed"; + if (gesture.lifecycle === "hold") this.resetHolds.delete(registration.action.id); + if (this.isCurrentDialRegistration(registration)) { + void this.reportDialCommandError( + registration, + new Error("Dial release ignored: command queue is full.") + ); + } + } + } + } + + private enqueueDialTap(registration: DialRegistration, gesture: DialGesture): void { + const accepted = registration.queue.enqueue(async () => { + if (!this.isCurrentDialRegistration(registration)) { + gesture.phase = "canceled"; + this.releaseDialGestureMutation(gesture); + return; + } + if (gesture.lifecycle === "hold") { + gesture.phase = "failed"; + await this.reportDialCommandError( + registration, + new Error("Rate-limit reset requires a dial press and hold.") + ); + return; + } + try { + await this.dispatchDialGesture(registration, gesture, 1); + if (gesture.lifecycle === "momentary") { + gesture.phase = "active"; + await this.releaseDialGesture( + registration, + gesture, + this.isCurrentDialRegistration(registration) + ); + } else { + gesture.phase = "completed"; + } + } catch (error) { + if (gesture.phase !== "released") gesture.phase = "failed"; + if (this.isCurrentDialRegistration(registration)) { + await this.reportDialCommandError(registration, error); + } + } + }); + if (!accepted) { + gesture.phase = "failed"; + this.releaseDialGestureMutation(gesture); + if (this.isCurrentDialRegistration(registration)) { + void this.reportDialCommandError( + registration, + new Error("Dial gesture ignored: command queue is full.") + ); + } + } + } + + private async releaseDialGesture( + registration: DialRegistration, + gesture: DialGesture, + reportFailure: boolean + ): Promise { + if (gesture.phase !== "active") return; + gesture.phase = "releasing"; + try { + await this.dispatchDialGesture(registration, gesture, 0); + } catch (error) { + if (reportFailure) await this.reportDialCommandError(registration, error); + } finally { + gesture.phase = "released"; + } + } + + private async dispatchDialGesture( + registration: DialRegistration, + gesture: DialGesture, + act: 0 | 1 + ): Promise { + const { binding, route } = gesture; + if (!isDialBindingId(binding, "press")) throw new Error("Unsupported Codex dial binding."); + if (binding === "none") return; + if (route.kind === "invalid-agent") throw new Error("No Codex dial selection is available."); + if (route.kind === "agent") { + await this.sendDialAgent(route, act); + return; + } + const lifecycle = bindingLifecycle(binding); + if (act === 0 && lifecycle !== "momentary") return; + if (binding === "reasoning.decrease" || binding === "keycap.MIND-") { + await this.serializeModelReasoningMutation(async () => { + if (!this.isCurrentDialRegistration(registration)) return; + const result = await this.sendDialToHost( + route, + { + kind: "reasoning", direction: "decrease", + includeUltra: gesture.includeUltraReasoning, + includeReasoningFeedback: true + }, + () => this.adjustLocalReasoning("decrease", { + includeUltra: gesture.includeUltraReasoning + }) + ); + const outcome = reasoningResultOutcome(result); + if (outcome === "blocked-ultra") await this.showDialUltraOff(registration); + else if (reasoningResultEffort(result) !== undefined) { + await this.renderDialSafely(registration); + } + }, this.takeDialGestureMutation(gesture)); + return; + } + if (binding === "reasoning.increase" || binding === "keycap.MIND+") { + await this.serializeModelReasoningMutation(async () => { + if (!this.isCurrentDialRegistration(registration)) return; + const result = await this.sendDialToHost( + route, + { + kind: "reasoning", direction: "increase", + includeUltra: gesture.includeUltraReasoning, + includeReasoningFeedback: true + }, + () => this.adjustLocalReasoning("increase", { + includeUltra: gesture.includeUltraReasoning + }) + ); + const outcome = reasoningResultOutcome(result); + if (outcome === "blocked-ultra") await this.showDialUltraOff(registration); + else if (reasoningResultEffort(result) !== undefined) { + await this.renderDialSafely(registration); + } + }, this.takeDialGestureMutation(gesture)); + return; + } + if (binding === "new-task") { + await this.sendDialToHost( + route, + { kind: "keycap", keycapId: "NEW" }, + () => openCodexThread("new") + ); + return; + } + if (binding === "host.toggle") return this.toggleTargetHost(); + if (binding === "usage.refresh") return this.refreshDialUsage(route); + if (binding === "usage.toggle-overview") { + registration.state = { + ...registration.state, + usageOverview: !registration.state.usageOverview + }; + await this.renderDialSafely(registration); + return; + } + if (binding === "usage.rate-limit-reset") { + throw new Error("Rate-limit reset requires a dial press and hold."); + } + if (binding.startsWith("micro.")) { + const slot = binding.slice(6) as MicroActionSlot; + await this.sendDialToHost( + route, + { kind: "action", slot, act }, + () => this.runLocalMicroAction(slot, act), + act === 1 + ); + return; + } + if (binding.startsWith("joystick.")) { + const direction = binding.slice(9) as MicroDirection; + await this.sendDialToHost( + route, + { kind: "joystick", direction, distance: act }, + () => this.microBridge.sendJoystick(direction, act), + act === 1 + ); + return; + } + if (binding.startsWith("keycap.") && act === 1) { + const keycapId = binding.slice(7) as OfficialKeycapId; + await this.sendDialToHost( + route, + { kind: "keycap", keycapId }, + () => this.runLocalKeycap(keycapId) + ); + return; + } + throw new Error("Unsupported Codex dial binding."); + } + + private async sendDialAgent(route: DialAgentRoute, act: 0 | 1): Promise { + if (act === 1) { + const current = this.routedSlots.find((candidate) => candidate.threadKey != null && + `${candidate.host.hostId}:${candidate.threadKey}` === route.identity); + if (!current || current.sourceSlot !== route.assignment.sourceSlot) { + throw new Error("The selected Codex task no longer matches the highlighted host and task."); + } + if (this.healthForHost(current.host).state !== "ready") { + throw new Error("The captured Codex agent host is not ready."); + } + route.assignment = { ...current, host: { ...current.host } }; + } + await this.sendAgentAssignment(route.assignment, act); + } + + private async sendDialToHost( + route: DialHostRoute, + command: RelayCommand, + local: () => Promise, + requireReady = true + ): Promise { + const localHost = this.localHost; + const localRequested = route.hostId != null + ? route.hostId === localHost?.hostId + : route.platform === localHost?.platform; + if (localRequested) { + if (requireReady && this.localHealth.state !== "ready") { + throw new Error("The captured Codex host is not ready."); + } + const execution = await local(); + const parsed = parseReasoningExecution(execution); + if (!parsed) return undefined; + return parsed.reasoningEffort === undefined ? parsed.outcome : parsed; + } + const remote = this.relayClient?.currentHost(); + if (!remote || (route.hostId != null + ? remote.hostId !== route.hostId + : remote.platform !== route.platform)) { + throw new Error("The captured Codex host is no longer connected."); + } + if (requireReady && this.relayClient?.currentHealth().state !== "ready") { + throw new Error("The captured Codex host is not ready."); + } + if (command.kind === "reasoning" && !command.includeUltra && + !this.relayClient?.supportsCurrentReadyCapability(RELAY_REASONING_POLICY_CAPABILITY)) { + throw new Error("Remote Codex host does not support reasoning policy controls."); + } + const result = await this.sendRemote(command); + if (typeof result === "string" || result === undefined) return result; + return parseReasoningExecution(result); + } + + private async sendModelPresetToHost( + route: DialHostRoute, + request: ModelPresetRequest + ): Promise { + const lifecycleGeneration = this.controllerLifecycleGeneration; + const localHost = this.localHost; + const localRequested = route.hostId != null + ? route.hostId === localHost?.hostId + : route.platform === localHost?.platform; + if (!localRequested) { + const remote = this.relayClient?.currentHost(); + if (!remote || (route.hostId != null + ? remote.hostId !== route.hostId + : remote.platform !== route.platform)) { + throw new Error("The captured Codex host is no longer connected."); + } + if (this.relayClient?.currentHealth().state !== "ready" || + !this.relayClient.supportsCapabilityForSnapshot( + RELAY_MODEL_PRESETS_CAPABILITY, remote.hostId, remote.platform + )) { + throw new Error("Remote Codex host does not support model preset controls."); + } + const result = await this.sendRemote({ + kind: "model-preset", + modelId: request.modelId, + reasoningEffort: request.reasoningEffort, + includeUltra: request.includeUltra, + includeModelPresetFeedback: true + }); + const parsed = parseModelPresetExecution(result); + if (!parsed || parsed.modelId !== request.modelId || + parsed.reasoningEffort !== request.reasoningEffort) { + throw new Error("Remote Codex returned an invalid or unconfirmed model preset result."); + } + return parsed; + } + if (this.localHealth.state !== "ready") { + throw new Error("The captured Codex host is not ready."); + } + + const mutationGeneration = ++this.localSnapshotGeneration; + let reconcilingSupersededResult = false; + try { + const parsed = parseModelPresetExecution(await this.microBridge.applyModelPreset(request)); + this.assertControllerLifecycle(lifecycleGeneration); + const resultObservationFloor = this.localSnapshotGeneration; + if (!parsed || parsed.modelId !== request.modelId || + parsed.reasoningEffort !== request.reasoningEffort) { + throw new Error("Codex returned an invalid or unconfirmed model preset result."); + } + if (this.localSnapshotGeneration !== mutationGeneration) { + reconcilingSupersededResult = true; + return await this.reconcileSupersededLocalModelPreset( + lifecycleGeneration, localHost, resultObservationFloor); + } + const current = this.localSnapshot; + if (!localHost || this.localHost?.hostId !== localHost.hostId || + this.localHost.platform !== localHost.platform || !current || + current.host.hostId !== localHost.hostId || current.host.platform !== localHost.platform) { + throw new Error("The captured Codex host changed before model preset confirmation."); + } + const catalogEntry = current.snapshot.modelCatalog?.find((entry) => + entry.modelId === parsed.modelId && + entry.supportedReasoningEfforts.includes(parsed.reasoningEffort)); + if (!catalogEntry) { + throw new Error("The confirmed Codex model preset is absent from the authoritative catalog."); + } + this.localSnapshot = { + ...current, + snapshot: { + ...current.snapshot, + activeModelId: parsed.modelId, + activeModelDisplayName: catalogEntry.displayName, + reasoningEffort: parsed.reasoningEffort + } + }; + this.committedLocalSnapshotGeneration = mutationGeneration; + return parsed; + } catch (error) { + this.assertControllerLifecycle(lifecycleGeneration); + if (reconcilingSupersededResult) throw error; + try { + if (this.refreshInFlight) await this.refreshInFlight; + this.assertControllerLifecycle(lifecycleGeneration); + await this.refresh(); + this.assertControllerLifecycle(lifecycleGeneration); + } catch { + // refresh() records degraded health; retain the original command error for the dial alert. + } + throw error; + } + } + + private authoritativeLocalModelPreset(localHost?: CodexHost): ModelPresetExecution | undefined { + const current = this.localSnapshot; + const modelId = current?.snapshot.activeModelId; + const reasoningEffort = current?.snapshot.reasoningEffort; + if (this.committedLocalSnapshotGeneration !== this.localSnapshotGeneration || + this.localHealth.state !== "ready" || localHost == null || current == null || + this.localHost?.hostId !== localHost.hostId || this.localHost.platform !== localHost.platform || + current.host.hostId !== localHost.hostId || current.host.platform !== localHost.platform || + typeof modelId !== "string" || !isSafeReasoningIdentifier(reasoningEffort)) { + return undefined; + } + const catalogEntry = current.snapshot.modelCatalog?.find((entry) => + entry.modelId === modelId && entry.supportedReasoningEfforts.includes(reasoningEffort)); + if (!catalogEntry) return undefined; + return { modelId, reasoningEffort }; + } + + private async reconcileSupersededLocalModelPreset( + lifecycleGeneration: number, + localHost: CodexHost | undefined, + resultObservationFloor: number + ): Promise { + const pendingRefresh = this.refreshInFlight; + if (pendingRefresh) { + try { await pendingRefresh; } + catch { /* Authority and health are checked below. */ } + if (this.refreshInFlight === pendingRefresh) this.refreshInFlight = undefined; + this.assertControllerLifecycle(lifecycleGeneration); + const committed = this.authoritativeLocalModelPreset(localHost); + if (this.committedLocalSnapshotGeneration > resultObservationFloor && committed) { + return committed; + } + } + this.assertControllerLifecycle(lifecycleGeneration); + try { await this.refresh(); } + catch { /* Authority and health are checked below. */ } + this.assertControllerLifecycle(lifecycleGeneration); + const authoritative = this.authoritativeLocalModelPreset(localHost); + if (this.committedLocalSnapshotGeneration > resultObservationFloor && authoritative) { + return authoritative; + } + this.localHealth = { + state: "degraded", + reason: "local-bridge-unavailable", + changedAt: Date.now() + }; + throw new Error("Authoritative model preset state is unavailable after confirmation."); + } + + private async refreshDialUsage(route: DialHostRoute): Promise { + const localRequested = route.hostId != null + ? route.hostId === this.localHost?.hostId + : route.platform === this.localHost?.platform; + if (localRequested) { + if (this.localHealth.state !== "ready") { + throw new Error("The captured Codex usage host is not ready."); + } + await this.refreshLocalUsage(); + return; + } + const remote = this.relayClient?.currentHost(); + if (!remote || (route.hostId != null + ? remote.hostId !== route.hostId + : remote.platform !== route.platform)) { + throw new Error("The captured Codex usage host is no longer connected."); + } + if (!this.relayClient?.supportsCapabilityForSnapshot( + "usage-refresh", remote.hostId, remote.platform + )) { + throw new Error("Remote Codex host does not support usage refresh."); + } + await this.relayClient.send({ kind: "usage-refresh" }); + } + + private async reportDialCommandError( + registration: DialRegistration, + error: unknown + ): Promise { + streamDeck.logger.error(`Codex dial command failed (${registration.action.id}): ${String(error)}`); + try { await registration.action.showAlert(); } + catch (alertError) { + streamDeck.logger.error(`Codex dial alert failed (${registration.action.id}): ${String(alertError)}`); + } + } + private async renderAgent({ action, slot }: AgentRegistration): Promise { const agent = this.routedSlots[slot]; const health = agent ? this.healthForHost(agent.host) : this.targetHealth(); @@ -446,15 +2200,30 @@ export class DeckController { const snapshot = this.targetSnapshot(); const keycapId = snapshot?.layout.slots[slot]?.keycapId; if (!keycapId) return; - const image = await this.keycapImage(keycapId, snapshot?.theme ?? "dark"); + const toggleState = keycapId === "FAST" ? snapshot.fastModeEnabled : undefined; + const image = await this.keycapImage(keycapId, snapshot.theme, toggleState); + const current = this.targetSnapshot(); + const currentKeycapId = current?.layout.slots[slot]?.keycapId; + const currentToggleState = currentKeycapId === "FAST" ? current?.fastModeEnabled : undefined; + if (currentKeycapId !== keycapId || current?.theme !== snapshot.theme || currentToggleState !== toggleState) return; if (image) await this.setImage(action, image); } private async renderFixedAction(registration: FixedIconRegistration): Promise { - const theme = this.targetSnapshot()?.theme ?? "dark"; + const snapshot = this.targetSnapshot(); + const theme = snapshot?.theme ?? "dark"; + const toggleState = registration.source.kind === "local" && registration.source.keycapId === "FAST" + ? snapshot?.fastModeEnabled + : undefined; const image = registration.source.kind === "builtin" ? renderBuiltinKeycap(registration.source.name, theme) - : await this.keycapImage(registration.source.keycapId, theme); + : await this.keycapImage(registration.source.keycapId, theme, toggleState); + const current = this.targetSnapshot(); + const currentTheme = current?.theme ?? "dark"; + const currentToggleState = registration.source.kind === "local" && registration.source.keycapId === "FAST" + ? current?.fastModeEnabled + : undefined; + if (currentTheme !== theme || currentToggleState !== toggleState) return; if (image) await this.setImage(registration.action, image); } @@ -480,8 +2249,10 @@ export class DeckController { private async renderRateLimitReset(action: KeyAction): Promise { const source = this.accountUsageSource(); const snapshot = source.snapshot; - const startedAt = this.resetHolds.get(action.id); - const progress = startedAt == null ? 0 : Math.min(1, (Date.now() - startedAt) / RESET_HOLD_MS); + const hold = this.resetHolds.get(action.id); + const progress = hold == null + ? 0 + : Math.min(1, (Date.now() - hold.startedAt) / RESET_HOLD_MS); await this.setImage(action, renderRateLimitResetKey( snapshot?.usage?.resetCreditsAvailable ?? null, progress, @@ -529,13 +2300,50 @@ export class DeckController { return selectAccountUsageSource(local, remote); } + private accountUsageSourceForHost(hostId: string): AccountUsageSource { + if (hostId === this.localHost?.hostId) { + return { + health: this.localHealth, + hostId, + snapshot: this.localSnapshot?.snapshot + }; + } + const remoteSnapshot = this.relayClient?.currentSnapshot(); + if (remoteSnapshot?.host.hostId === hostId) { + return { + health: this.relayClient?.currentHealth() ?? { + state: "offline", reason: "relay-disconnected", changedAt: Date.now() + }, + hostId, + snapshot: remoteSnapshot.snapshot + }; + } + return { + health: { state: "offline", reason: "relay-disconnected", changedAt: Date.now() }, + hostId + }; + } + + private hostRoute(hostId: string | undefined): DialHostRoute { + if (hostId != null && hostId === this.localHost?.hostId) { + return { kind: "host", hostId, platform: this.localHost.platform }; + } + const remote = this.relayClient?.currentHost(); + if (hostId != null && hostId === remote?.hostId) { + return { kind: "host", hostId, platform: remote.platform }; + } + return { kind: "host", hostId, platform: this.targetPlatform }; + } + private isRemoteTarget(): boolean { return this.localHost != null && this.targetPlatform !== this.localHost.platform; } - private async sendRemote(command: RelayCommand): Promise { + private async sendRemote( + command: RelayCommand + ): Promise { if (!this.relayClient) throw new Error("Remote Codex relay is not configured."); - await this.relayClient.send(command); + return this.relayClient.send(command); } private async sendToTarget(command: RelayCommand, local: () => Promise): Promise { @@ -560,6 +2368,28 @@ export class DeckController { return target; } + private async runLocalMicroAction(slot: MicroActionSlot, act: 0 | 1): Promise { + const refreshFastState = act === 1 && + this.localSnapshot?.snapshot.layout.slots[slot]?.keycapId === "FAST"; + await this.microBridge.sendAction(slot, act); + if (refreshFastState) await this.refreshAfterFastActivation(); + } + + private async runLocalKeycap(keycapId: OfficialKeycapId): Promise { + await this.microBridge.runKeycap(keycapId); + if (keycapId === "FAST") await this.refreshAfterFastActivation(); + } + + private async refreshAfterFastActivation(): Promise { + const precedingRefresh = this.refreshInFlight; + if (precedingRefresh) { + try { await precedingRefresh; } + catch { /* The original refresh owner retains its failure; FAST still needs a newer snapshot. */ } + } + if (this.stopped) return; + await this.refresh(); + } + private async setImage(action: KeyAction, image: string): Promise { if (this.lastImages.get(action.id) === image) return; await Promise.all([action.setImage(image), action.setTitle("")]); @@ -589,19 +2419,116 @@ export class DeckController { if (this.stopped) return; this.animation = setTimeout(async () => { this.animationFrame = (this.animationFrame + 1) % 12; - try { await Promise.all([this.renderAnimatedAgents(), this.renderResetHolds()]); } + try { + await Promise.all([ + this.renderAnimatedAgents().catch((error) => + streamDeck.logger.error(`Agent animation frame failed: ${String(error)}`)), + this.renderResetHolds().catch((error) => + streamDeck.logger.error(`Reset hold animation frame failed: ${String(error)}`)) + ]); + } finally { this.scheduleAnimation(); } }, 200); } - private keycapImage(keycapId: string, theme: "light" | "dark"): Promise { - const cacheKey = `${theme}:${keycapId}`; + private keycapImage( + keycapId: string, + theme: "light" | "dark", + toggleState?: boolean + ): Promise { + const stateKey = toggleState == null ? "unknown" : toggleState ? "on" : "off"; + const cacheKey = `${theme}:${keycapId}:${stateKey}`; let pending = this.keycapImages.get(cacheKey); if (pending) return pending; pending = readFile(join(USER_ICON_ROOT, `${keycapId}.svg`), "utf8") - .then((svg) => renderImportedKeycap(svg, theme)) - .catch(() => renderFallbackKeycap(keycapId, theme)); + .then((svg) => renderImportedKeycap(svg, theme, toggleState)) + .catch(() => renderFallbackKeycap(keycapId, theme, toggleState)); this.keycapImages.set(cacheKey, pending); return pending; } } + +function parseReasoningExecution(value: unknown): ReasoningAdjustmentExecution | undefined { + try { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + const expected = ["outcome", ...(Object.prototype.hasOwnProperty.call(descriptors, "reasoningEffort") + ? ["reasoningEffort"] : [])]; + if (keys.length !== expected.length || keys.some((key) => + typeof key !== "string" || !expected.includes(key))) return undefined; + const outcomeProperty = descriptors.outcome; + const effortProperty = descriptors.reasoningEffort; + if (!outcomeProperty || outcomeProperty.enumerable !== true || !("value" in outcomeProperty) || + (effortProperty && (effortProperty.enumerable !== true || !("value" in effortProperty)))) { + return undefined; + } + const outcome = outcomeProperty.value; + const reasoningEffort = effortProperty?.value; + if (outcome !== "applied" && outcome !== "blocked-ultra") return undefined; + if (reasoningEffort !== undefined && !isSafeReasoningIdentifier(reasoningEffort)) return undefined; + return { + outcome, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }) + }; + } catch { return undefined; } +} + +function isAppliedReasoningExecutionWithUnconfirmedEffort(value: unknown): boolean { + try { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + const hasEffort = Object.prototype.hasOwnProperty.call(descriptors, "reasoningEffort"); + const expected = ["outcome", ...(hasEffort ? ["reasoningEffort"] : [])]; + if (keys.length !== expected.length || keys.some((key) => + typeof key !== "string" || !expected.includes(key))) return false; + const outcomeProperty = descriptors.outcome; + const effortProperty = descriptors.reasoningEffort; + if (!outcomeProperty || outcomeProperty.enumerable !== true || !("value" in outcomeProperty) || + (effortProperty && (effortProperty.enumerable !== true || !("value" in effortProperty)))) { + return false; + } + return outcomeProperty.value === "applied" && + (!effortProperty || !isSafeReasoningIdentifier(effortProperty.value)); + } catch { return false; } +} + +function parseModelPresetExecution(value: unknown): ModelPresetExecution | undefined { + try { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if (keys.length !== 2 || keys.some((key) => + key !== "modelId" && key !== "reasoningEffort")) return undefined; + const modelProperty = descriptors.modelId; + const effortProperty = descriptors.reasoningEffort; + if (!modelProperty || !effortProperty || modelProperty.enumerable !== true || + effortProperty.enumerable !== true || !("value" in modelProperty) || + !("value" in effortProperty)) return undefined; + if (!isSafeReasoningIdentifier(modelProperty.value, 128) || + !isSafeReasoningIdentifier(effortProperty.value, 64)) return undefined; + return { + modelId: modelProperty.value, + reasoningEffort: effortProperty.value + }; + } catch { return undefined; } +} + +function reasoningResultOutcome( + result: ReasoningAdjustmentResult | ReasoningAdjustmentExecution | undefined +): ReasoningAdjustmentResult | undefined { + return typeof result === "string" ? result : result?.outcome; +} + +function reasoningResultEffort( + result: ReasoningAdjustmentResult | ReasoningAdjustmentExecution | undefined +): string | undefined { + return typeof result === "object" ? result.reasoningEffort : undefined; +} diff --git a/src/dial-action.ts b/src/dial-action.ts new file mode 100644 index 0000000..78d4e01 --- /dev/null +++ b/src/dial-action.ts @@ -0,0 +1,62 @@ +import { + action, + type DialDownEvent, + type DialRotateEvent, + type DialUpEvent, + type DidReceiveSettingsEvent, + type PropertyInspectorDidAppearEvent, + type PropertyInspectorDidDisappearEvent, + type SendToPluginEvent, + SingletonAction, + type TouchTapEvent, + type WillAppearEvent, + type WillDisappearEvent +} from "@elgato/streamdeck"; +import type { JsonValue } from "@elgato/utils"; +import type { DeckController } from "./controller.js"; +import type { CodexDialSettings } from "./dial-types.js"; + +@action({ UUID: "com.simeo.codex-deck.codex-dial" }) +export class CodexDialAction extends SingletonAction { + constructor(private readonly controller: DeckController) { super(); } + + override onWillAppear(ev: WillAppearEvent): void { + if (ev.action.isDial()) this.controller.registerDial(ev.action, ev.payload.settings); + } + + override onDidReceiveSettings(ev: DidReceiveSettingsEvent): void { + if (ev.action.isDial()) this.controller.updateDialSettings(ev.action, ev.payload.settings); + } + + override onWillDisappear(ev: WillDisappearEvent): void { + this.controller.unregisterDial(ev.action); + } + + override onPropertyInspectorDidAppear(ev: PropertyInspectorDidAppearEvent): void { + if (ev.action.isDial()) this.controller.registerDialPropertyInspector(ev.action); + } + + override onPropertyInspectorDidDisappear(ev: PropertyInspectorDidDisappearEvent): void { + if (ev.action.isDial()) this.controller.unregisterDialPropertyInspector(ev.action); + } + + override onSendToPlugin(ev: SendToPluginEvent): void { + if (ev.action.isDial()) this.controller.handleDialPropertyInspectorMessage(ev.action, ev.payload); + } + + override onDialRotate(ev: DialRotateEvent): void { + this.controller.rotateDial(ev.action, ev.payload.ticks); + } + + override async onDialDown(ev: DialDownEvent): Promise { + await this.controller.beginDialPress(ev.action); + } + + override async onDialUp(ev: DialUpEvent): Promise { + await this.controller.finishDialPress(ev.action); + } + + override async onTouchTap(ev: TouchTapEvent): Promise { + if (!ev.payload.hold) await this.controller.touchDial(ev.action); + } +} diff --git a/src/dial-domain.ts b/src/dial-domain.ts new file mode 100644 index 0000000..dd99ab8 --- /dev/null +++ b/src/dial-domain.ts @@ -0,0 +1,974 @@ +import { OFFICIAL_KEYCAP_IDS } from "./keycaps.js"; +import { isSafeReasoningIdentifier } from "./types.js"; +import { + DIAL_FEEDBACK_MODES, + DIAL_PRESETS, + DIAL_SELECTOR_SOURCES, + type CodexDialSettings, + type DialBindingId, + type DialFeedback, + type DialFeedbackMode, + type DialPreset, + type DialRuntimeState, + type DialRuntimeView, + type ExistingDialSettingsV2, + type LegacyDialFeedbackMode, + type LegacyDialPreset, + type LegacyDialRotation, + type ModelPresetEntry, + type ModelPresetDirection, + type ModelPresetResolution, + type ModelPresetsDialSettings, + type DialSelectorItem, + type DialSelectorSource +} from "./dial-types.js"; + +export const MICRO_SLOTS = Object.freeze( + ["ACT06", "ACT07", "ACT08", "ACT09", "ACT10_ACT11", "ACT12"] as const +); +export const JOYSTICK_DIRECTIONS = Object.freeze(["up", "right", "down", "left"] as const); +export const DEFAULT_ACTION_SELECTOR_ITEMS: readonly DialBindingId[] = Object.freeze( + MICRO_SLOTS.map((slot): DialBindingId => `micro.${slot}`) +); +export const MAX_DIAL_TICKS_PER_EVENT = 64; +export const MAX_DIAL_QUEUE_PENDING = 128; + +export function bindingLifecycle( + binding: DialBindingId +): "none" | "one-shot" | "momentary" | "hold" { + if (binding === "none") return "none"; + if (binding === "usage.rate-limit-reset") return "hold"; + if (binding.startsWith("micro.") || binding.startsWith("joystick.")) return "momentary"; + return "one-shot"; +} + +export class DialCommandQueue { + private tail: Promise = Promise.resolve(); + private pending = 0; + + get pendingCount(): number { + return this.pending; + } + + canEnqueue(count = 1): boolean { + return Number.isSafeInteger(count) && count >= 0 && + this.pending + count <= MAX_DIAL_QUEUE_PENDING; + } + + enqueue(operation: () => Promise): boolean { + if (!this.canEnqueue()) return false; + this.pending += 1; + const run = async (): Promise => { + try { await operation(); } + finally { this.pending -= 1; } + }; + this.tail = this.tail.then(run, run).catch(() => undefined); + return true; + } + + enqueueCleanup(operation: () => Promise): void { + this.tail = this.tail.then(operation, operation).catch(() => undefined); + } + + idle(): Promise { + return this.tail; + } +} + +const PRESET_IDS = new Set(DIAL_PRESETS); +const FEEDBACK_MODE_IDS = new Set(DIAL_FEEDBACK_MODES); +const SELECTOR_SOURCE_IDS = new Set(DIAL_SELECTOR_SOURCES); +const MICRO_SLOT_IDS = new Set(MICRO_SLOTS); +const JOYSTICK_DIRECTION_IDS = new Set(JOYSTICK_DIRECTIONS); +const OFFICIAL_KEYCAP_ID_SET = new Set(OFFICIAL_KEYCAP_IDS); +const DIAL_ACCENTS = Object.freeze({ + blue: "#1683FF", + green: "#35D86B", + orange: "#FF9A3D", + red: "#FF4B61", + teal: "#4CE0C2", + muted: "#707B85" +} as const); + +export function initialDialRuntimeState(): DialRuntimeState { + return { usageMode: "auto", usageOverview: false }; +} + +export function selectorItems( + settings: CodexDialSettings, + view: DialRuntimeView +): DialSelectorItem[] { + if (settings.rotation.kind !== "selector") return []; + if (settings.rotation.source === "agents") { + return view.agents.map((agent) => { + const displayNumber = agentDisplayNumber(agent.id); + return { + id: agent.identity, + label: cleanDisplaySource(agent.title) ?? `Agent ${displayNumber}`, + detail: cleanDisplaySource(agent.status) ?? "unknown", + agentSlot: agent.id, + threadKey: agent.threadKey + }; + }); + } + if (settings.rotation.source === "usage") { + return [ + { id: "auto", label: "Auto" }, + { id: "five-hour", label: "5h" }, + { id: "weekly", label: "Weekly" } + ]; + } + return settings.rotation.items.map((binding) => ({ + id: binding, + label: cleanActionLabel(view.actionLabels[binding]) ?? dialBindingLabel(binding), + binding + })); +} + +export function reconcileSelector( + state: DialRuntimeState, + items: readonly DialSelectorItem[] +): DialRuntimeState { + if (items.length === 0) { + const { selectedId: _selectedId, ...cleared } = state; + return { ...cleared }; + } + const stableIndex = items.findIndex((item) => item.id === state.selectedId); + const priorIndex = Number.isSafeInteger(state.selectedIndex) && state.selectedIndex != null + ? state.selectedIndex + : 0; + const selectedIndex = stableIndex >= 0 + ? stableIndex + : Math.min(items.length - 1, Math.max(0, priorIndex)); + return { ...state, selectedId: items[selectedIndex]!.id, selectedIndex }; +} + +export function isDialTickCount(ticks: number): boolean { + return Number.isSafeInteger(ticks) && Math.abs(ticks) <= MAX_DIAL_TICKS_PER_EVENT; +} + +export function reduceDialRotation( + settings: CodexDialSettings, + state: DialRuntimeState, + view: DialRuntimeView, + ticks: number +): { state: DialRuntimeState; bindings: DialBindingId[] } { + if (!isDialTickCount(ticks) || ticks === 0) { + return { state, bindings: [] }; + } + if (settings.rotation.kind === "model-presets") { + return { state, bindings: [] }; + } + if (settings.rotation.kind === "paired") { + const binding = ticks > 0 + ? settings.rotation.clockwise + : settings.rotation.counterClockwise; + const bindings: DialBindingId[] = []; + for (let detent = 0; detent < Math.abs(ticks); detent += 1) bindings.push(binding); + return { state, bindings }; + } + + const items = selectorItems(settings, view); + const reconciled = reconcileSelector(state, items); + if (items.length === 0) return { state: reconciled, bindings: [] }; + const currentIndex = reconciled.selectedIndex ?? 0; + const nextIndex = settings.rotation.wrap + ? modulo(currentIndex + (ticks % items.length), items.length) + : Math.min(items.length - 1, Math.max(0, currentIndex + ticks)); + const selectedId = items[nextIndex]!.id; + return { + state: { + ...reconciled, + selectedId, + selectedIndex: nextIndex, + ...(settings.rotation.source === "usage" + ? { usageMode: usageModeFromId(selectedId) } + : {}) + }, + bindings: [] + }; +} + +export function selectedItem( + settings: CodexDialSettings, + state: DialRuntimeState, + view: DialRuntimeView +): DialSelectorItem | undefined { + const items = selectorItems(settings, view); + const reconciled = reconcileSelector(state, items); + return items.find((item) => item.id === reconciled.selectedId); +} + +export function resolveModelPresetDirection( + settings: CodexDialSettings, + view: DialRuntimeView, + direction: ModelPresetDirection +): ModelPresetResolution { + if (settings.preset !== "model-presets") return { kind: "unavailable" }; + if (settings.modelPresets.length === 0) return { kind: "empty" }; + const entries = validModelPresetEntries(settings, view); + if (entries == null) return { kind: "unavailable" }; + if (entries.length === 0) return { kind: "empty" }; + const activeIndex = entries.findIndex(({ entry }) => + entry.modelId === view.activeModelId && entry.reasoningEffort === view.reasoningEffort); + const targetIndex = activeIndex < 0 + ? direction === "clockwise" ? 0 : entries.length - 1 + : modulo(activeIndex + (direction === "clockwise" ? 1 : -1), entries.length); + return { + kind: "target", + entry: { ...entries[targetIndex]!.entry }, + index: targetIndex, + count: entries.length + }; +} + +export function deriveDialFeedback( + settings: CodexDialSettings, + state: DialRuntimeState, + view: DialRuntimeView +): DialFeedback { + const mode = resolveFeedbackMode(settings); + const live = mode === "reasoning" + ? reasoningFeedback(view) + : mode === "agent" + ? agentFeedback(settings, state, view) + : mode === "action" + ? actionFeedback(settings, state, view) + : mode === "navigation" + ? feedback("NAVIGATION", "BACK / FORWARD", "TURN LEFT / RIGHT", 50, DIAL_ACCENTS.blue) + : mode === "usage" + ? usageFeedback(state, view) + : mode === "model-presets" + ? modelPresetsFeedback(settings, state, view) + : staticFeedback(settings); + const emptySelector = (mode === "agent" || mode === "action") && + selectorItems(settings, view).length === 0; + if (mode === "agent" || emptySelector) return live; + return view.health === "ready" ? live : healthFeedback(live.title, view.health); +} + +export function expandDialPreset(preset: DialPreset): CodexDialSettings { + if (preset === "model-presets") { + return { + version: 2, + preset: "model-presets", + customized: false, + includeUltraReasoning: false, + rotation: { kind: "model-presets" }, + press: "none", + touchTap: "keycap.FAST", + feedback: "model-presets", + modelPresets: [] + }; + } + if (preset === "agents") { + return selectorPreset("agents", "selector.activate", "keycap.TIME", "agent"); + } + if (preset === "actions") { + return selectorPreset( + "actions", "selector.activate", "keycap.SETUP", "action", [...DEFAULT_ACTION_SELECTOR_ITEMS] + ); + } + if (preset === "usage") { + return selectorPreset("usage", "usage.toggle-overview", "usage.refresh", "usage"); + } + if (preset === "navigation") { + return pairedPreset( + "navigation", "joystick.left", "joystick.right", "joystick.up", "joystick.down", "navigation" + ); + } + if (preset === "custom") { + return pairedPreset("custom", "none", "none", "none", "none", "static"); + } + return pairedPreset( + "reasoning", "reasoning.decrease", "reasoning.increase", "none", "keycap.FAST", "reasoning" + ); +} + +export function isDialBindingId( + value: unknown, + gesture: "rotation" | "press" | "touch" | "selector" = "press" +): value is DialBindingId { + if (typeof value !== "string") return false; + const valid = value === "none" || value === "selector.activate" || + value === "reasoning.decrease" || value === "reasoning.increase" || + value === "new-task" || value === "host.toggle" || value === "usage.refresh" || + value === "usage.toggle-overview" || value === "usage.rate-limit-reset" || + (value.startsWith("micro.") && MICRO_SLOT_IDS.has(value.slice(6))) || + (value.startsWith("joystick.") && JOYSTICK_DIRECTION_IDS.has(value.slice(9))) || + (value.startsWith("keycap.") && OFFICIAL_KEYCAP_ID_SET.has(value.slice(7))); + if (!valid) return false; + if (value === "usage.rate-limit-reset" && gesture !== "press") return false; + if (value === "selector.activate" && gesture !== "press") return false; + return true; +} + +export function normalizeDialSettings(input: unknown): CodexDialSettings { + const version = ownDataValue(input, "version"); + const preset = ownDataValue(input, "preset"); + if (version === 2 && isPreset(preset)) { + const fallback = expandDialPreset(preset); + try { + return normalizeVersion2(input, preset) ?? fallback; + } catch { + return fallback; + } + } + if (version !== 1 || !isLegacyPreset(preset)) { + return expandDialPreset("reasoning"); + } + + if (!record(input)) return expandDialPreset("reasoning"); + const fallback = expandDialPreset(preset) as ExistingDialSettingsV2; + const staticLabelValue = ownDataValue(input, "staticLabel"); + const staticLabel = typeof staticLabelValue === "string" + ? staticLabelValue.trim().slice(0, 40) + : undefined; + const rotation = normalizeRotation(ownDataValue(input, "rotation"), fallback.rotation); + const pressValue = ownDataValue(input, "press"); + const requestedPress = isDialBindingId(pressValue, "press") + ? pressValue + : fallback.press; + const touchTapValue = ownDataValue(input, "touchTap"); + const feedbackValue = ownDataValue(input, "feedback"); + return { + version: 2, + preset, + customized: ownDataValue(input, "customized") === true, + includeUltraReasoning: ownDataValue(input, "includeUltraReasoning") === true, + rotation, + press: rotation.kind === "paired" && requestedPress === "selector.activate" ? "none" : requestedPress, + touchTap: isDialBindingId(touchTapValue, "touch") + ? touchTapValue + : "none", + feedback: isLegacyFeedback(feedbackValue) + ? feedbackValue + : fallback.feedback, + ...(staticLabel ? { staticLabel } : {}) + }; +} + +function normalizeVersion2(input: unknown, preset: DialPreset): CodexDialSettings | undefined { + const requiredKeys = [ + "version", "preset", "customized", "includeUltraReasoning", "rotation", "press", + "touchTap", "feedback", ...(preset === "model-presets" ? ["modelPresets"] : []) + ]; + if (!exactPlainDataRecord(input, requiredKeys, ["staticLabel"])) return undefined; + if (ownDataValue(input, "version") !== 2 || ownDataValue(input, "preset") !== preset) { + return undefined; + } + const customized = ownDataValue(input, "customized"); + const includeUltraReasoning = ownDataValue(input, "includeUltraReasoning"); + const press = ownDataValue(input, "press"); + const touchTap = ownDataValue(input, "touchTap"); + const staticLabel = ownDataValue(input, "staticLabel"); + if (typeof customized !== "boolean" || typeof includeUltraReasoning !== "boolean" || + !isDialBindingId(press, "press") || !isDialBindingId(touchTap, "touch") || + (Object.hasOwn(input, "staticLabel") && + (typeof staticLabel !== "string" || staticLabel.length === 0 || + staticLabel.length > 40 || staticLabel.trim() !== staticLabel))) { + return undefined; + } + + if (preset === "model-presets") { + const rotation = ownDataValue(input, "rotation"); + const feedbackMode = ownDataValue(input, "feedback"); + const entries = normalizeModelPresetEntries(ownDataValue(input, "modelPresets")); + if (!exactPlainDataRecord(rotation, ["kind"]) || + ownDataValue(rotation, "kind") !== "model-presets" || + feedbackMode !== "model-presets" || press === "selector.activate" || entries == null) { + return undefined; + } + return { + version: 2, + preset, + customized, + includeUltraReasoning, + rotation: { kind: "model-presets" }, + press, + touchTap, + feedback: "model-presets", + modelPresets: entries, + ...(typeof staticLabel === "string" ? { staticLabel } : {}) + }; + } + + const rotation = strictLegacyRotation(ownDataValue(input, "rotation")); + const feedbackMode = ownDataValue(input, "feedback"); + if (rotation == null || !isLegacyFeedback(feedbackMode) || + (rotation.kind === "paired" && press === "selector.activate")) return undefined; + return { + version: 2, + preset, + customized, + includeUltraReasoning, + rotation, + press, + touchTap, + feedback: feedbackMode, + ...(typeof staticLabel === "string" ? { staticLabel } : {}) + }; +} + +function strictLegacyRotation(value: unknown): LegacyDialRotation | undefined { + const kind = ownDataValue(value, "kind"); + if (kind === "paired") { + if (!exactPlainDataRecord(value, ["kind", "counterClockwise", "clockwise"])) return undefined; + const counterClockwise = ownDataValue(value, "counterClockwise"); + const clockwise = ownDataValue(value, "clockwise"); + if (!isDialBindingId(counterClockwise, "rotation") || + !isDialBindingId(clockwise, "rotation")) return undefined; + return { kind, counterClockwise, clockwise }; + } + if (kind !== "selector" || + !exactPlainDataRecord(value, ["kind", "source", "wrap", "items"])) return undefined; + const source = ownDataValue(value, "source"); + const wrap = ownDataValue(value, "wrap"); + const items = ownDataValue(value, "items"); + const itemValues = ownDataArrayValues(items, 30); + if (!isSelectorSource(source) || typeof wrap !== "boolean" || itemValues == null) { + return undefined; + } + const bindings: DialBindingId[] = []; + for (const item of itemValues) { + if (!isDialBindingId(item, "selector") || bindings.includes(item)) return undefined; + bindings.push(item); + } + if (source !== "actions" && bindings.length !== 0) return undefined; + return { kind, source, wrap, items: bindings }; +} + +function normalizeModelPresetEntries(value: unknown): ModelPresetEntry[] | undefined { + const values = ownDataArrayValues(value, 12); + if (values == null) return undefined; + const entries: ModelPresetEntry[] = []; + const seen = new Set(); + for (const item of values) { + if (!exactPlainDataRecord(item, ["modelId", "reasoningEffort"])) return undefined; + const modelId = ownDataValue(item, "modelId"); + const reasoningEffort = ownDataValue(item, "reasoningEffort"); + if (!isSafeReasoningIdentifier(modelId, 128) || + !isSafeReasoningIdentifier(reasoningEffort)) return undefined; + const identity = `${modelId}\u0000${reasoningEffort}`; + if (seen.has(identity)) return undefined; + seen.add(identity); + entries.push({ modelId, reasoningEffort }); + } + return entries; +} + +function record(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + try { + return !Array.isArray(value); + } catch { + return false; + } +} + +function ownDataValue(value: unknown, key: string): unknown { + try { + if (!record(value)) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor != null && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +} + +function exactPlainDataRecord( + value: unknown, + requiredKeys: readonly string[], + optionalKeys: readonly string[] = [] +): value is Record { + if (!record(value) || Object.getPrototypeOf(value) !== Object.prototype) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Object.getOwnPropertySymbols(value).length !== 0) return false; + const keys = Object.keys(descriptors); + if (keys.length < requiredKeys.length || + keys.some((key) => !requiredKeys.includes(key) && !optionalKeys.includes(key)) || + requiredKeys.some((key) => !Object.hasOwn(descriptors, key))) return false; + return keys.every((key) => { + const descriptor = descriptors[key]; + return descriptor != null && "value" in descriptor && descriptor.enumerable === true; + }); +} + +function ownDataArrayValues(value: unknown, maximum: number): unknown[] | undefined { + try { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || + Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value) as Record; + const lengthDescriptor = descriptors["length"]; + const length = lengthDescriptor?.value; + if (lengthDescriptor == null || !("value" in lengthDescriptor) || + !Number.isSafeInteger(length) || typeof length !== "number" || length < 0 || + length > maximum) return undefined; + const expectedKeys = Array.from( + { length }, (_, index) => String(index) + ); + const keys = Object.keys(descriptors).filter((key) => key !== "length"); + if (keys.length !== expectedKeys.length || + keys.some((key, index) => key !== expectedKeys[index])) return undefined; + const values: unknown[] = []; + for (const key of expectedKeys) { + const descriptor = descriptors[key]; + if (descriptor == null || !("value" in descriptor) || descriptor.enumerable !== true) { + return undefined; + } + values.push(descriptor.value); + } + return values; + } catch { + return undefined; + } +} + +function isPreset(value: unknown): value is DialPreset { + return typeof value === "string" && PRESET_IDS.has(value); +} + +function isLegacyPreset(value: unknown): value is LegacyDialPreset { + return isPreset(value) && value !== "model-presets"; +} + +function isFeedback(value: unknown): value is DialFeedbackMode { + return typeof value === "string" && FEEDBACK_MODE_IDS.has(value); +} + +function isLegacyFeedback(value: unknown): value is LegacyDialFeedbackMode { + return isFeedback(value) && value !== "model-presets"; +} + +function isSelectorSource(value: unknown): value is DialSelectorSource { + return typeof value === "string" && SELECTOR_SOURCE_IDS.has(value); +} + +function pairedPreset( + preset: LegacyDialPreset, + counterClockwise: DialBindingId, + clockwise: DialBindingId, + press: DialBindingId, + touchTap: DialBindingId, + feedback: LegacyDialFeedbackMode +): ExistingDialSettingsV2 { + return { + version: 2, + preset, + customized: false, + includeUltraReasoning: false, + rotation: { kind: "paired", counterClockwise, clockwise }, + press, + touchTap, + feedback + }; +} + +function selectorPreset( + preset: DialSelectorSource, + press: DialBindingId, + touchTap: DialBindingId, + feedback: LegacyDialFeedbackMode, + items: DialBindingId[] = [] +): ExistingDialSettingsV2 { + return { + version: 2, + preset, + customized: false, + includeUltraReasoning: false, + rotation: { kind: "selector", source: preset, wrap: true, items }, + press, + touchTap, + feedback + }; +} + +function normalizeRotation(value: unknown, fallback: LegacyDialRotation): LegacyDialRotation { + try { + const kind = ownDataValue(value, "kind"); + if (kind === "paired") { + const counterClockwise = ownDataValue(value, "counterClockwise"); + const clockwise = ownDataValue(value, "clockwise"); + return { + kind, + counterClockwise: isDialBindingId(counterClockwise, "rotation") + ? counterClockwise + : "none", + clockwise: isDialBindingId(clockwise, "rotation") ? clockwise : "none" + }; + } + if (kind !== "selector") return fallback; + const source = ownDataValue(value, "source"); + const wrap = ownDataValue(value, "wrap"); + const items = ownDataValue(value, "items"); + const itemValues = ownDataArrayValues(items, 10_000); + if (!isSelectorSource(source) || typeof wrap !== "boolean" || itemValues == null) { + return fallback; + } + return { + kind, + source, + wrap, + items: source === "actions" + ? itemValues.filter((item): item is DialBindingId => + isDialBindingId(item, "selector")) + .filter((item, index, candidates) => candidates.indexOf(item) === index) + .slice(0, 30) + : [] + }; + } catch { + return fallback; + } +} + +function cleanActionLabel(value: string | undefined): string | undefined { + if (value == null) return undefined; + const clean = value.replace(/\s+/g, " ").trim(); + return clean || undefined; +} + +export function dialBindingLabel(binding: DialBindingId): string { + const fixed: Partial> = { + none: "None", + "selector.activate": "Activate Selection", + "reasoning.decrease": "Reasoning Decrease", + "reasoning.increase": "Reasoning Increase", + "new-task": "New Task", + "host.toggle": "Toggle Host", + "usage.refresh": "Refresh Usage", + "usage.toggle-overview": "Toggle Usage Overview", + "usage.rate-limit-reset": "Rate Limit Reset" + }; + const known = fixed[binding]; + if (known) return known; + if (binding.startsWith("micro.")) return binding.slice(6).replaceAll("_", " + "); + if (binding.startsWith("joystick.")) return `Joystick ${titleCase(binding.slice(9))}`; + if (binding.startsWith("keycap.")) return `Keycap ${binding.slice(7).replaceAll("_", " ")}`; + return "Action"; +} + +function titleCase(value: string): string { + return value.replace(/(^|[-_ ])([a-z])/g, (_match, separator: string, letter: string) => + `${separator === "-" || separator === "_" ? " " : separator}${letter.toUpperCase()}`); +} + +function agentDisplayNumber(id: number): number { + return Number.isSafeInteger(id) && id >= 0 ? id + 1 : 1; +} + +function modulo(value: number, divisor: number): number { + return ((value % divisor) + divisor) % divisor; +} + +function usageModeFromId(id: string): DialRuntimeState["usageMode"] { + if (id === "five-hour") return "five-hour"; + if (id === "weekly") return "weekly"; + return "auto"; +} + +function resolveFeedbackMode(settings: CodexDialSettings): Exclude { + if (settings.feedback !== "auto") return settings.feedback; + if (settings.rotation.kind === "selector") { + if (settings.rotation.source === "agents") return "agent"; + if (settings.rotation.source === "actions") return "action"; + return "usage"; + } + if (settings.preset === "reasoning") return "reasoning"; + if (settings.preset === "agents") return "agent"; + if (settings.preset === "actions") return "action"; + if (settings.preset === "navigation") return "navigation"; + if (settings.preset === "usage") return "usage"; + if (settings.rotation.counterClockwise.startsWith("reasoning.") || + settings.rotation.clockwise.startsWith("reasoning.")) return "reasoning"; + if (settings.rotation.counterClockwise.startsWith("joystick.") || + settings.rotation.clockwise.startsWith("joystick.")) return "navigation"; + return "static"; +} + +function validModelPresetEntries( + settings: ModelPresetsDialSettings, + view: DialRuntimeView +): Array<{ entry: ModelPresetEntry; displayName: string }> | undefined { + if (view.modelCatalog == null) return undefined; + const valid: Array<{ entry: ModelPresetEntry; displayName: string }> = []; + for (const entry of settings.modelPresets) { + if (!settings.includeUltraReasoning && entry.reasoningEffort.toLowerCase() === "ultra") continue; + const model = view.modelCatalog.find((candidate) => candidate.modelId === entry.modelId); + if (model == null || !model.supportedReasoningEfforts.includes(entry.reasoningEffort)) continue; + valid.push({ entry, displayName: model.displayName }); + } + return valid; +} + +function modelPresetsFeedback( + settings: CodexDialSettings, + state: DialRuntimeState, + view: DialRuntimeView +): DialFeedback { + if (settings.preset !== "model-presets") { + return feedback("MODEL PRESET", "UNAVAILABLE", "LIVE VALUE NOT REPORTED", 0, DIAL_ACCENTS.muted); + } + if (state.modelPresetSwitching === true) { + return feedback("MODEL PRESET", "SWITCHING…", "WAIT", 0, DIAL_ACCENTS.blue); + } + if (settings.modelPresets.length === 0) { + return feedback("MODEL PRESET", "NO PRESETS", "ADD IN SETTINGS", 0, DIAL_ACCENTS.muted); + } + const entries = validModelPresetEntries(settings, view); + if (entries == null || view.activeModelId == null || view.reasoningEffort == null) { + return feedback("MODEL PRESET", "UNAVAILABLE", "LIVE VALUE NOT REPORTED", 0, DIAL_ACCENTS.muted); + } + if (entries.length === 0) { + return feedback("MODEL PRESET", "NO PRESETS", "ADD IN SETTINGS", 0, DIAL_ACCENTS.muted); + } + const index = entries.findIndex(({ entry }) => + entry.modelId === view.activeModelId && entry.reasoningEffort === view.reasoningEffort); + if (index >= 0) { + return feedback( + `MODEL PRESET ${index + 1}/${entries.length}`, + entries[index]!.displayName, + titleCase(view.reasoningEffort), + ((index + 1) / entries.length) * 100, + DIAL_ACCENTS.teal + ); + } + const activeCatalogEntry = view.modelCatalog?.find((entry) => entry.modelId === view.activeModelId); + return feedback( + "MODEL PRESET", + cleanDisplaySource(view.activeModelDisplayName) ?? activeCatalogEntry?.displayName ?? view.activeModelId, + `${titleCase(view.reasoningEffort)} · UNLISTED`, + 0, + DIAL_ACCENTS.muted + ); +} + +function reasoningFeedback(view: DialRuntimeView): DialFeedback { + const effort = cleanDisplaySource(view.reasoningEffort); + if (!effort) { + return feedback( + "REASONING", "UNAVAILABLE", "LIVE VALUE NOT REPORTED", 0, DIAL_ACCENTS.muted + ); + } + const indicators: Record = { + minimal: 10, + low: 25, + medium: 50, + high: 75, + xhigh: 100 + }; + return feedback( + "REASONING", + effort, + "TURN TO ADJUST", + indicators[effort.toLowerCase()] ?? 50, + DIAL_ACCENTS.blue + ); +} + +function agentFeedback( + settings: CodexDialSettings, + state: DialRuntimeState, + view: DialRuntimeView +): DialFeedback { + const items = selectorItems(settings, view); + if (items.length === 0) { + return feedback("AGENT", "NO ITEMS", "NO ACTIVE AGENTS", 0, DIAL_ACCENTS.muted); + } + const reconciled = reconcileSelector(state, items); + const item = items.find((candidate) => candidate.id === reconciled.selectedId); + if (!item) { + return feedback("AGENT", "UNAVAILABLE", "SELECTION UNAVAILABLE", 0, DIAL_ACCENTS.muted); + } + const agent = view.agents.find((candidate) => candidate.identity === item.id); + if (!agent) { + return feedback("AGENT", "UNAVAILABLE", "SELECTION UNAVAILABLE", 0, DIAL_ACCENTS.muted); + } + const context = finitePercent(agent.contextUsedPercent); + const status = item.detail ?? "unknown"; + const detail = context == null + ? status + : `${status} • ${Math.round(context)}% context`; + const live = feedback( + `AGENT ${agentDisplayNumber(agent.id)}${agent.hostBadge ? ` • ${agent.hostBadge}` : ""}`, + item.label, + detail, + context ?? 0, + agentAccent(status) + ); + return agent.health === "ready" ? live : healthFeedback(live.title, agent.health); +} + +function actionFeedback( + settings: CodexDialSettings, + state: DialRuntimeState, + view: DialRuntimeView +): DialFeedback { + const items = selectorItems(settings, view); + const reconciled = reconcileSelector(state, items); + const index = items.findIndex((item) => item.id === reconciled.selectedId); + const item = index >= 0 ? items[index] : undefined; + if (items.length === 0) { + return feedback("ACTION", "NO ITEMS", "NO ACTIONS CONFIGURED", 0, DIAL_ACCENTS.muted); + } + if (!item) { + return feedback("ACTION", "UNAVAILABLE", "SELECTION UNAVAILABLE", 0, DIAL_ACCENTS.muted); + } + return feedback( + `ACTION ${index + 1}/${items.length}`, + item.label, + "PRESS TO RUN", + ((index + 1) / items.length) * 100, + DIAL_ACCENTS.teal + ); +} + +function usageFeedback(state: DialRuntimeState, view: DialRuntimeView): DialFeedback { + const usage = view.usage; + if (state.usageOverview) { + if (!usage) { + return feedback( + "USAGE OVERVIEW", "UNAVAILABLE", "LIVE VALUE NOT REPORTED", 0, DIAL_ACCENTS.muted + ); + } + const fiveHour = finitePercent(usage.fiveHourRemaining); + const weekly = finitePercent(usage.weeklyRemaining); + const value = `5H ${percentOrDash(fiveHour)} • WK ${percentOrDash(weekly)}`; + const indicator = selectedUsageRemaining(state.usageMode, usage) ?? fiveHour ?? weekly ?? 0; + return feedback( + "USAGE OVERVIEW", + value, + "PRESS TO CLOSE", + indicator, + usageAccent(fiveHour ?? weekly) + ); + } + + const effectiveMode = state.usageMode === "auto" ? usage?.mode ?? "auto" : state.usageMode; + const title = `USAGE • ${usageModeLabel(effectiveMode)}`; + const remaining = usage ? selectedUsageRemaining(state.usageMode, usage) : undefined; + if (remaining == null) { + return feedback(title, "UNAVAILABLE", "LIVE VALUE NOT REPORTED", 0, DIAL_ACCENTS.muted); + } + const resetMatchesSelection = usage != null && + (state.usageMode === "auto" || state.usageMode === usage.mode); + const detail = resetMatchesSelection + ? resetCountdown(usage.resetsAt, view.now) + : "RESET UNAVAILABLE"; + return feedback( + title, + `${Math.round(remaining)}% LEFT`, + detail, + remaining, + usageAccent(remaining) + ); +} + +function staticFeedback(settings: CodexDialSettings): DialFeedback { + return feedback( + "CUSTOM", + cleanDisplaySource(settings.staticLabel) ?? "CODEX DIAL", + "READY", + 0, + DIAL_ACCENTS.muted + ); +} + +function healthFeedback( + title: string, + health: Exclude +): DialFeedback { + if (health === "offline") { + return feedback(title, "OFFLINE", "LIVE DATA UNAVAILABLE", 0, DIAL_ACCENTS.red); + } + if (health === "connecting") { + return feedback(title, "CONNECTING", "WAITING FOR LIVE DATA", 0, DIAL_ACCENTS.orange); + } + return feedback(title, "DEGRADED", "LIVE DATA MAY BE STALE", 0, DIAL_ACCENTS.orange); +} + +function selectedUsageRemaining( + mode: DialRuntimeState["usageMode"], + usage: NonNullable +): number | undefined { + if (mode === "five-hour") return finitePercent(usage.fiveHourRemaining); + if (mode === "weekly") return finitePercent(usage.weeklyRemaining); + return finitePercent(usage.remainingPercent) + ?? (usage.mode === "weekly" + ? finitePercent(usage.weeklyRemaining) + : finitePercent(usage.fiveHourRemaining)) + ?? finitePercent(usage.weeklyRemaining); +} + +function usageModeLabel(mode: DialRuntimeState["usageMode"]): string { + if (mode === "five-hour") return "5 HOURS"; + if (mode === "weekly") return "WEEKLY"; + return "AUTOMATIC"; +} + +function percentOrDash(value: number | undefined): string { + return value == null ? "—" : `${Math.round(value)}%`; +} + +function resetCountdown(resetsAt: number | null | undefined, now: number): string { + if (resetsAt == null || !Number.isFinite(resetsAt) || !Number.isFinite(now)) { + return "RESET UNAVAILABLE"; + } + const minutes = Math.max(0, Math.ceil((resetsAt - now) / 60_000)); + const days = Math.floor(minutes / (24 * 60)); + const hours = Math.floor((minutes % (24 * 60)) / 60); + const remainder = minutes % 60; + const units = [ + ...(days > 0 ? [`${days}D`] : []), + ...(hours > 0 ? [`${hours}H`] : []), + ...(remainder > 0 || (days === 0 && hours === 0) ? [`${remainder}M`] : []) + ]; + return `RESETS IN ${units.join(" ")}`; +} + +function usageAccent(remaining: number | undefined): string { + if (remaining == null) return DIAL_ACCENTS.muted; + return remaining <= 20 ? DIAL_ACCENTS.red : DIAL_ACCENTS.green; +} + +function agentAccent(status: string): string { + const normalized = status.trim().toLowerCase(); + if (normalized === "error") return DIAL_ACCENTS.red; + if (normalized === "complete") return DIAL_ACCENTS.green; + if (normalized === "input") return DIAL_ACCENTS.orange; + if (normalized === "thinking" || normalized === "working") return DIAL_ACCENTS.blue; + return DIAL_ACCENTS.muted; +} + +function finitePercent(value: number | null | undefined): number | undefined { + if (value == null || !Number.isFinite(value)) return undefined; + return Math.min(100, Math.max(0, value)); +} + +function cleanDisplaySource(value: string | undefined): string | undefined { + if (value == null) return undefined; + const clean = value.replace(/\s+/g, " ").trim(); + return clean || undefined; +} + +function feedback( + title: string, + value: string, + detail: string, + indicator: number, + accent: string +): DialFeedback { + return { + title: displayText(title, 18), + value: displayText(value, 24), + detail: displayText(detail, 32), + indicator: finitePercent(indicator) ?? 0, + accent + }; +} + +function displayText(value: string, maximum: number): string { + const uppercase = value.replace(/\s+/g, " ").trim().toUpperCase(); + const codePoints = Array.from(uppercase); + if (codePoints.length <= maximum) return uppercase; + return `${codePoints.slice(0, maximum - 1).join("").trimEnd()}…`; +} diff --git a/src/dial-types.ts b/src/dial-types.ts new file mode 100644 index 0000000..937125a --- /dev/null +++ b/src/dial-types.ts @@ -0,0 +1,153 @@ +import type { OfficialKeycapId } from "./keycaps.js"; +import type { HostHealthState, MicroActionSlot, MicroDirection } from "./types.js"; + +export type JsonValue = boolean | number | string | null | undefined | JsonObject | JsonValue[]; +export interface JsonObject { [key: string]: JsonValue } + +export const DIAL_PRESETS = Object.freeze( + ["reasoning", "agents", "actions", "navigation", "usage", "custom", "model-presets"] as const +); +export type DialPreset = typeof DIAL_PRESETS[number]; +export type LegacyDialPreset = Exclude; + +export const DIAL_FEEDBACK_MODES = Object.freeze( + ["auto", "reasoning", "agent", "action", "navigation", "usage", "static", "model-presets"] as const +); +export type DialFeedbackMode = typeof DIAL_FEEDBACK_MODES[number]; +export type LegacyDialFeedbackMode = Exclude; + +export const DIAL_SELECTOR_SOURCES = Object.freeze(["agents", "actions", "usage"] as const); +export type DialSelectorSource = typeof DIAL_SELECTOR_SOURCES[number]; + +export type DialBindingId = + | "none" | "selector.activate" + | "reasoning.decrease" | "reasoning.increase" + | "new-task" | "host.toggle" + | "usage.refresh" | "usage.toggle-overview" | "usage.rate-limit-reset" + | `micro.${MicroActionSlot}` | `joystick.${MicroDirection}` | `keycap.${OfficialKeycapId}`; + +export interface PairedDialRotation extends JsonObject { + kind: "paired"; + counterClockwise: DialBindingId; + clockwise: DialBindingId; +} + +export interface SelectorDialRotation extends JsonObject { + kind: "selector"; + source: DialSelectorSource; + wrap: boolean; + items: DialBindingId[]; +} + +export interface ModelPresetRotation extends JsonObject { + kind: "model-presets"; +} + +export interface ModelPresetEntry extends JsonObject { + modelId: string; + reasoningEffort: string; +} + +export type LegacyDialRotation = PairedDialRotation | SelectorDialRotation; +export type DialRotation = LegacyDialRotation | ModelPresetRotation; + +export interface CodexDialSettingsBase extends JsonObject { + customized: boolean; + includeUltraReasoning: boolean; + press: DialBindingId; + touchTap: DialBindingId; + staticLabel?: string; +} + +export interface CodexDialSettingsV1 extends CodexDialSettingsBase { + version: 1; + preset: LegacyDialPreset; + rotation: LegacyDialRotation; + feedback: LegacyDialFeedbackMode; +} + +export interface ExistingDialSettingsV2 extends CodexDialSettingsBase { + version: 2; + preset: LegacyDialPreset; + rotation: LegacyDialRotation; + feedback: LegacyDialFeedbackMode; +} + +export interface ModelPresetsDialSettings extends CodexDialSettingsBase { + version: 2; + preset: "model-presets"; + rotation: ModelPresetRotation; + feedback: "model-presets"; + modelPresets: ModelPresetEntry[]; +} + +export type CodexDialSettings = ExistingDialSettingsV2 | ModelPresetsDialSettings; + +export type ModelPresetDirection = "clockwise" | "counter-clockwise"; +export type ModelPresetResolution = + | { kind: "target"; entry: ModelPresetEntry; index: number; count: number } + | { kind: "empty" } + | { kind: "unavailable" }; + +export type DialSelectorItem = { + id: string; + label: string; + detail?: string; + binding?: DialBindingId; + agentSlot?: number; + threadKey?: string; +}; + +export type DialRuntimeState = { + selectedId?: string; + selectedIndex?: number; + usageMode: "auto" | "five-hour" | "weekly"; + usageOverview: boolean; + modelPresetSwitching?: boolean; +}; + +export type DialRuntimeModelCatalogEntry = { + modelId: string; + displayName: string; + supportedReasoningEfforts: string[]; +}; + +export type DialRuntimeAgent = { + id: number; + identity: string; + threadKey: string; + title: string; + status: string; + health: HostHealthState; + hostBadge?: "M" | "W"; + contextUsedPercent?: number; +}; + +export type DialRuntimeUsage = { + mode: "auto" | "five-hour" | "weekly"; + remainingPercent?: number; + resetsAt?: number | null; + observedAt?: number; + fiveHourRemaining?: number; + weeklyRemaining?: number; +}; + +export type DialRuntimeView = { + health: HostHealthState; + reasoningEffort?: string; + activeModelId?: string; + activeModelDisplayName?: string; + modelCatalog?: DialRuntimeModelCatalogEntry[]; + agents: DialRuntimeAgent[]; + actionLabels: Partial>; + usage?: DialRuntimeUsage; + now: number; +}; + +export type DialFeedback = { + title: string; + value: string; + detail: string; + indicator: number; + accent: string; +}; diff --git a/src/plugin.ts b/src/plugin.ts index b276e2f..652d208 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,6 @@ import streamDeck from "@elgato/streamdeck"; import { DeckController } from "./controller.js"; +import { CodexDialAction } from "./dial-action.js"; import { Agent1, Agent2, Agent3, Agent4, Agent5, Agent6, Approve, Back, Decline, Dictation, Fast, Fork, Forward, NewTask, @@ -20,6 +21,7 @@ streamDeck.settings.onDidReceiveGlobalSettings<{ showContextRings?: boolean }>(( }); for (const pluginAction of [ + new CodexDialAction(controller), new Agent1(controller), new Agent2(controller), new Agent3(controller), new Agent4(controller), new Agent5(controller), new Agent6(controller), new Fast(controller), new Approve(controller), new Decline(controller), diff --git a/src/relay-protocol.ts b/src/relay-protocol.ts index 6bab6a1..e1c3dca 100644 --- a/src/relay-protocol.ts +++ b/src/relay-protocol.ts @@ -1,6 +1,8 @@ import { OFFICIAL_KEYCAP_IDS, type OfficialKeycapId } from "./keycaps.js"; +import { isSafeReasoningIdentifier } from "./types.js"; import type { - CodexHost, HostSessionPresence, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, RoutedAgentSlot + CodexHost, HostSessionPresence, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, + ReasoningAdjustmentResult, RoutedAgentSlot } from "./types.js"; export const RELAY_PROTOCOL_VERSION = 1; @@ -10,13 +12,31 @@ export type RelayCommand = | { kind: "action"; slot: MicroActionSlot; act: 0 | 1 } | { kind: "joystick"; direction: MicroDirection; distance: 0 | 1 } | { kind: "encoder"; act: 0 | 1 } - | { kind: "reasoning"; direction: ReasoningAdjustment } + | { + kind: "reasoning"; + direction: ReasoningAdjustment; + includeUltra: boolean; + includeReasoningFeedback?: true; + } + | { + kind: "model-preset"; + modelId: string; + reasoningEffort: string; + includeUltra: boolean; + includeModelPresetFeedback: true; + } + | { kind: "usage-refresh" } | { kind: "rate-limit-reset" } | { kind: "keycap"; keycapId: OfficialKeycapId }; export type RelayAuthMessage = { type: "auth"; protocol: 1; token: string }; +export const RELAY_REASONING_POLICY_CAPABILITY = "reasoning-policy"; +export const RELAY_REASONING_FEEDBACK_CAPABILITY = "reasoning-feedback"; +export const RELAY_MODEL_PRESETS_CAPABILITY = "model-presets"; export const RELAY_CAPABILITIES = [ - "agent", "action", "joystick", "encoder", "reasoning", "keycap", "usage", "rate-limit-reset" + "agent", "action", "joystick", "encoder", "reasoning", RELAY_REASONING_POLICY_CAPABILITY, + RELAY_REASONING_FEEDBACK_CAPABILITY, RELAY_MODEL_PRESETS_CAPABILITY, + "keycap", "usage", "usage-refresh", "rate-limit-reset" ] as const; export type RelayReadyMessage = { type: "ready"; @@ -41,7 +61,24 @@ export type RelayHealthMessage = { observedAt: number; }; export type RelayCommandMessage = { type: "command"; protocol: 1; requestId: string; command: RelayCommand }; -export type RelayResultMessage = { type: "result"; protocol: 1; requestId: string; ok: boolean; error?: string }; +export type RelayResultMessage = + | { + type: "result"; + protocol: 1; + requestId: string; + ok: true; + outcome?: ReasoningAdjustmentResult; + reasoningEffort?: string; + } + | { + type: "result"; + protocol: 1; + requestId: string; + ok: true; + modelId: string; + reasoningEffort: string; + } + | { type: "result"; protocol: 1; requestId: string; ok: false; error?: string }; export type RelayServerMessage = RelayReadyMessage | RelaySnapshotMessage | RelayHealthMessage | RelayResultMessage; export type HostSnapshot = { host: CodexHost; snapshot: MicroSnapshot; observedAt: number }; @@ -235,71 +272,310 @@ function emptyRoutedPosition(input: HostSnapshot, id: number): RoutedAgentSlot { } export function parseRelayServerMessage(value: unknown): RelayServerMessage | null { - if (!isRecord(value) || value.protocol !== RELAY_PROTOCOL_VERSION || typeof value.type !== "string") return null; - if (value.type === "ready" && isHost(value.host)) return value as RelayReadyMessage; - if (value.type === "snapshot" && isHost(value.host) && Number.isFinite(value.observedAt) && isSnapshot(value.snapshot)) { - return value as RelaySnapshotMessage; + try { return parseRelayServerMessageUnchecked(value); } + catch { return null; } +} + +function parseRelayServerMessageUnchecked(value: unknown): RelayServerMessage | null { + const message = snapshotOwnDataRecord(value); + if (!message || message.protocol !== RELAY_PROTOCOL_VERSION || typeof message.type !== "string") return null; + const hasCapabilities = Object.prototype.hasOwnProperty.call(message, "capabilities"); + const hasBridge = Object.prototype.hasOwnProperty.call(message, "bridge"); + const capabilities = hasCapabilities ? snapshotOwnDataArray(message.capabilities, 32) : undefined; + if (message.type === "ready" && + exactOwnDataKeys(message, [ + "type", "protocol", "host", ...(hasCapabilities ? ["capabilities"] : []), + ...(hasBridge ? ["bridge"] : []) + ]) && isHost(message.host) && + (message.bridge === undefined || message.bridge === "native-codex-micro") && + (capabilities === undefined || (capabilities !== null && + capabilities.every((item) => boundedNonblankString(item, 64))))) { + return message as RelayReadyMessage; + } + if (message.type === "snapshot" && + exactOwnDataKeys(message, ["type", "protocol", "host", "observedAt", "snapshot"]) && + isHost(message.host) && validProtocolTimestamp(message.observedAt) != null && isSnapshot(message.snapshot)) { + return message as RelaySnapshotMessage; } - if (value.type === "health" && isHost(value.host) && value.state === "degraded" && - value.reason === "native-signals-unavailable" && Number.isFinite(value.observedAt)) { - return value as RelayHealthMessage; + if (message.type === "health" && + exactOwnDataKeys(message, ["type", "protocol", "host", "state", "reason", "observedAt"]) && + isHost(message.host) && message.state === "degraded" && + message.reason === "native-signals-unavailable" && validProtocolTimestamp(message.observedAt) != null) { + return message as RelayHealthMessage; } - if (value.type === "result" && typeof value.requestId === "string" && typeof value.ok === "boolean") { - return value as RelayResultMessage; + if (message.type === "result" && boundedNonblankString(message.requestId, 128)) { + const hasOutcome = message.outcome !== undefined; + const hasReasoningEffort = message.reasoningEffort !== undefined; + const hasModelId = message.modelId !== undefined; + if (message.ok === true && hasModelId && hasReasoningEffort && !hasOutcome && + isSafeReasoningIdentifier(message.modelId, 128) && + isSafeReasoningIdentifier(message.reasoningEffort) && + exactOwnDataKeys(message, [ + "type", "protocol", "requestId", "ok", "modelId", "reasoningEffort" + ])) { + return message as RelayResultMessage; + } + if (message.ok === true && + !hasModelId && + (!hasOutcome || message.outcome === "applied" || message.outcome === "blocked-ultra") && + (!hasReasoningEffort || (hasOutcome && isSafeReasoningIdentifier(message.reasoningEffort))) && + exactOwnDataKeys(message, [ + "type", "protocol", "requestId", "ok", + ...(hasOutcome ? ["outcome"] : []), + ...(hasReasoningEffort ? ["reasoningEffort"] : []) + ])) { + return message as RelayResultMessage; + } + if (message.ok === false && + (message.error === undefined || (typeof message.error === "string" && message.error.length <= 512)) && + exactOwnDataKeys(message, Object.prototype.hasOwnProperty.call(message, "error") + ? ["type", "protocol", "requestId", "ok", "error"] + : ["type", "protocol", "requestId", "ok"])) { + return message as RelayResultMessage; + } } return null; } export function parseRelayCommand(value: unknown): RelayCommand | null { - if (!isRecord(value) || typeof value.kind !== "string") return null; - if (value.kind === "agent" && integerIn(value.slot, 0, 5) && isThreadKey(value.threadKey) && binary(value.act)) return value as RelayCommand; - if (value.kind === "action" && ["ACT06", "ACT07", "ACT08", "ACT09", "ACT10_ACT11", "ACT12"].includes(String(value.slot)) && binary(value.act)) return value as RelayCommand; - if (value.kind === "joystick" && ["up", "right", "down", "left"].includes(String(value.direction)) && binary(value.distance)) return value as RelayCommand; - if (value.kind === "encoder" && binary(value.act)) return value as RelayCommand; - if (value.kind === "reasoning" && ["decrease", "increase"].includes(String(value.direction))) return value as RelayCommand; - if (value.kind === "rate-limit-reset") return value as RelayCommand; - if (value.kind === "keycap" && typeof value.keycapId === "string" && OFFICIAL_KEYCAP_IDS.includes(value.keycapId as OfficialKeycapId)) return value as RelayCommand; + const command = snapshotOwnDataRecord(value); + if (!command || typeof command.kind !== "string") return null; + if (command.kind === "agent" && integerIn(command.slot, 0, 5) && isThreadKey(command.threadKey) && binary(command.act)) return command as RelayCommand; + if (command.kind === "action" && typeof command.slot === "string" && + ["ACT06", "ACT07", "ACT08", "ACT09", "ACT10_ACT11", "ACT12"].includes(command.slot) && binary(command.act)) return command as RelayCommand; + if (command.kind === "joystick" && typeof command.direction === "string" && + ["up", "right", "down", "left"].includes(command.direction) && binary(command.distance)) return command as RelayCommand; + if (command.kind === "encoder" && binary(command.act)) return command as RelayCommand; + if (command.kind === "reasoning" && (command.direction === "decrease" || command.direction === "increase") && + typeof command.includeUltra === "boolean" && + (command.includeReasoningFeedback === undefined || command.includeReasoningFeedback === true) && + exactOwnDataKeys(command, ["kind", "direction", "includeUltra", + ...(command.includeReasoningFeedback === true ? ["includeReasoningFeedback"] : [])])) { + return command as RelayCommand; + } + if (command.kind === "model-preset" && + isSafeReasoningIdentifier(command.modelId, 128) && + isSafeReasoningIdentifier(command.reasoningEffort) && + typeof command.includeUltra === "boolean" && + command.includeModelPresetFeedback === true && + exactOwnDataKeys(command, [ + "kind", "modelId", "reasoningEffort", "includeUltra", "includeModelPresetFeedback" + ])) { + return command as RelayCommand; + } + if (command.kind === "usage-refresh" && exactOwnDataKeys(command, ["kind"])) return command as RelayCommand; + if (command.kind === "rate-limit-reset" && exactOwnDataKeys(command, ["kind"])) return command as RelayCommand; + if (command.kind === "keycap" && typeof command.keycapId === "string" && OFFICIAL_KEYCAP_IDS.includes(command.keycapId as OfficialKeycapId)) return command as RelayCommand; return null; } +export function parseRelayCommandMessage(value: unknown): RelayCommandMessage | null { + const message = snapshotOwnDataRecord(value); + if (!message || !exactOwnDataKeys(message, ["type", "protocol", "requestId", "command"]) || + message.type !== "command" || message.protocol !== RELAY_PROTOCOL_VERSION || + !boundedNonblankString(message.requestId, 128)) return null; + const command = parseRelayCommand(message.command); + return command ? { + type: "command", protocol: RELAY_PROTOCOL_VERSION, + requestId: message.requestId, command + } : null; +} + function isSnapshot(value: unknown): value is MicroSnapshot { - if (!isRecord(value) || !Array.isArray(value.slots) || value.slots.length !== 6 || !isRecord(value.layout)) return false; - if (!value.slots.every((slot, index) => isRecord(slot) && slot.id === index && typeof slot.status === "string" && - (slot.contextUsedPercent == null || finitePercent(slot.contextUsedPercent)))) return false; - if (value.activeThreadKey != null && !isThreadKey(value.activeThreadKey)) return false; - if (value.activeThreadTitle != null && (typeof value.activeThreadTitle !== "string" || value.activeThreadTitle.length > 240)) return false; - if (value.usage != null && !isUsageSnapshot(value.usage)) return false; - if (value.hostSessions == null) return true; - return Array.isArray(value.hostSessions) && value.hostSessions.length <= 128 && value.hostSessions.every((session) => - isRecord(session) && isThreadKey(session.threadId) && validTimestamp(session.activityAt) != null && - ["idle", "working", "complete"].includes(String(session.status)) && - (session.completionRevision == null || integerIn(session.completionRevision, 0, Number.MAX_SAFE_INTEGER)) && - (session.contextUsedPercent == null || finitePercent(session.contextUsedPercent)) - ); + const snapshot = snapshotOwnDataRecord(value); + if (!snapshot || !onlyAllowedOwnKeys(snapshot, [ + "slots", "reasoningEffort", "activeModelId", "activeModelDisplayName", "modelCatalog", + "fastModeEnabled", "activeThreadKey", "activeThreadTitle", "layout", "agentSource", + "lightingAutoOff", "theme", "usage", "hostSessions" + ])) return false; + const slots = snapshotOwnDataArray(snapshot.slots, 6); + if (!slots || slots.length !== 6 || !isLayout(snapshot.layout)) return false; + if (!slots.every((rawSlot, index) => { + const slot = snapshotOwnDataRecord(rawSlot); + return slot && onlyAllowedOwnKeys(slot, [ + "id", "threadKey", "title", "status", "selected", "activityAt", "ownedByHost", "contextUsedPercent" + ]) && slot.id === index && + (slot.threadKey === null || isThreadKey(slot.threadKey)) && + (slot.title === null || (typeof slot.title === "string" && slot.title.length <= 240)) && + boundedNonblankString(slot.status, 64) && typeof slot.selected === "boolean" && + (slot.activityAt === undefined || validProtocolTimestamp(slot.activityAt) != null) && + (slot.ownedByHost === undefined || typeof slot.ownedByHost === "boolean") && + (slot.contextUsedPercent === undefined || finitePercent(slot.contextUsedPercent)); + })) return false; + if (!(["pinned", "recent", "priority", "custom"] as const).includes(snapshot.agentSource as never)) return false; + if (!boundedNonblankString(snapshot.lightingAutoOff, 64) || !(["light", "dark"] as const).includes(snapshot.theme as never)) return false; + if (snapshot.activeThreadKey !== undefined && !isThreadKey(snapshot.activeThreadKey)) return false; + if (snapshot.activeThreadTitle !== undefined && (typeof snapshot.activeThreadTitle !== "string" || snapshot.activeThreadTitle.length > 240)) return false; + if (snapshot.reasoningEffort !== undefined && !isSafeReasoningIdentifier(snapshot.reasoningEffort)) return false; + if (snapshot.fastModeEnabled !== undefined && typeof snapshot.fastModeEnabled !== "boolean") return false; + const hasActiveModelId = Object.prototype.hasOwnProperty.call(snapshot, "activeModelId"); + const hasActiveModelDisplayName = Object.prototype.hasOwnProperty.call(snapshot, "activeModelDisplayName"); + const hasModelCatalog = Object.prototype.hasOwnProperty.call(snapshot, "modelCatalog"); + if (!(hasActiveModelId === hasActiveModelDisplayName && hasActiveModelId === hasModelCatalog)) return false; + if (hasModelCatalog && snapshot.reasoningEffort === undefined) return false; + if (hasModelCatalog && !isActiveModelCatalog( + snapshot.activeModelId, snapshot.activeModelDisplayName, snapshot.reasoningEffort, snapshot.modelCatalog + )) return false; + if (snapshot.usage !== undefined && !isUsageSnapshot(snapshot.usage)) return false; + if (snapshot.hostSessions === undefined) return true; + const sessions = snapshotOwnDataArray(snapshot.hostSessions, 128); + return sessions != null && sessions.every((rawSession) => { + const session = snapshotOwnDataRecord(rawSession); + return session && onlyAllowedOwnKeys(session, [ + "threadId", "activityAt", "status", "completionRevision", "contextUsedPercent" + ]) && isThreadKey(session.threadId) && validProtocolTimestamp(session.activityAt) != null && + typeof session.status === "string" && ["idle", "working", "complete"].includes(session.status) && + (session.completionRevision === undefined || integerIn(session.completionRevision, 0, Number.MAX_SAFE_INTEGER)) && + (session.contextUsedPercent === undefined || finitePercent(session.contextUsedPercent)); + }); +} + +function isActiveModelCatalog( + activeModelId: unknown, + activeDisplayName: unknown, + activeReasoningEffort: unknown, + value: unknown +): boolean { + if (!isSafeReasoningIdentifier(activeModelId, 128) || !boundedNonblankString(activeDisplayName, 80) || + !isSafeReasoningIdentifier(activeReasoningEffort)) return false; + const catalog = snapshotOwnDataArray(value, 32); + if (!catalog || catalog.length === 0) return false; + const seenModels = new Set(); + let activeMatch = false; + for (const rawEntry of catalog) { + const entry = snapshotOwnDataRecord(rawEntry); + if (!entry || !exactOwnDataKeys(entry, ["modelId", "displayName", "supportedReasoningEfforts"]) || + !isSafeReasoningIdentifier(entry.modelId, 128) || !boundedNonblankString(entry.displayName, 80) || + seenModels.has(entry.modelId)) return false; + const efforts = snapshotOwnDataArray(entry.supportedReasoningEfforts, 16); + if (!efforts || efforts.length === 0) return false; + const seenEfforts = new Set(); + for (const effort of efforts) { + if (!isSafeReasoningIdentifier(effort) || seenEfforts.has(effort)) return false; + seenEfforts.add(effort); + } + seenModels.add(entry.modelId); + if (entry.modelId === activeModelId) { + if (entry.displayName !== activeDisplayName || !seenEfforts.has(activeReasoningEffort)) return false; + activeMatch = true; + } + } + return activeMatch; +} + +function isLayout(value: unknown): value is MicroSnapshot["layout"] { + const layout = snapshotOwnDataRecord(value); + if (!layout || !exactOwnDataKeys(layout, ["version", "slots", "analogStick"]) || layout.version !== 1) return false; + const slots = snapshotOwnDataRecord(layout.slots); + const analogStick = snapshotOwnDataRecord(layout.analogStick); + if (!slots || !analogStick || !exactOwnDataKeys(analogStick, ["up", "right", "down", "left"])) return false; + const actionSlots: readonly MicroActionSlot[] = ["ACT06", "ACT07", "ACT08", "ACT09", "ACT10_ACT11", "ACT12"]; + if (!exactOwnDataKeys(slots, actionSlots)) return false; + if (!actionSlots.every((key) => { + const slot = snapshotOwnDataRecord(slots[key]); + return slot && onlyAllowedOwnKeys(slot, ["keycapId", "commandId"]) && typeof slot.keycapId === "string" && + OFFICIAL_KEYCAP_IDS.includes(slot.keycapId as OfficialKeycapId) && + (slot.commandId === undefined || (typeof slot.commandId === "string" && slot.commandId.length <= 128)); + })) return false; + return true; } function isUsageSnapshot(value: unknown): boolean { - if (!isRecord(value) || !Array.isArray(value.windows) || value.windows.length > 8 || validTimestamp(value.observedAt) == null) return false; - if (value.resetCreditsAvailable != null && !integerIn(value.resetCreditsAvailable, 0, Number.MAX_SAFE_INTEGER)) return false; - if (value.resetCreditsApplicable != null && !integerIn(value.resetCreditsApplicable, 0, Number.MAX_SAFE_INTEGER)) return false; - return value.windows.every((window) => isRecord(window) && typeof window.id === "string" && window.id.length <= 64 && - ["five-hour", "weekly", "other"].includes(String(window.kind)) && + const usage = snapshotOwnDataRecord(value); + if (!usage || !exactOwnDataKeys(usage, ["windows", "observedAt", "resetCreditsAvailable", "resetCreditsApplicable"]) || + validProtocolTimestamp(usage.observedAt) == null) return false; + const windows = snapshotOwnDataArray(usage.windows, 8); + if (!windows) return false; + if (usage.resetCreditsAvailable !== null && !integerIn(usage.resetCreditsAvailable, 0, Number.MAX_SAFE_INTEGER)) return false; + if (usage.resetCreditsApplicable !== null && !integerIn(usage.resetCreditsApplicable, 0, Number.MAX_SAFE_INTEGER)) return false; + return windows.every((rawWindow) => { + const window = snapshotOwnDataRecord(rawWindow); + return window && exactOwnDataKeys(window, [ + "id", "kind", "usedPercent", "remainingPercent", "windowDurationMins", "resetsAt" + ]) && boundedNonblankString(window.id, 64) && + typeof window.kind === "string" && ["five-hour", "weekly", "other"].includes(window.kind) && finitePercent(window.usedPercent) && finitePercent(window.remainingPercent) && - (window.windowDurationMins == null || (typeof window.windowDurationMins === "number" && Number.isFinite(window.windowDurationMins) && window.windowDurationMins > 0)) && - (window.resetsAt == null || validTimestamp(window.resetsAt) != null)); + (window.windowDurationMins === null || positiveBoundedNumber(window.windowDurationMins)) && + (window.resetsAt === null || validProtocolTimestamp(window.resetsAt) != null); + }); } function isHost(value: unknown): value is CodexHost { - return isRecord(value) && typeof value.hostId === "string" && typeof value.hostName === "string" && - ["win32", "darwin"].includes(String(value.platform)) && - (value.codexVersion == null || (typeof value.codexVersion === "string" && value.codexVersion.length <= 64)); + const host = snapshotOwnDataRecord(value); + return host != null && onlyAllowedOwnKeys(host, ["hostId", "hostName", "platform", "codexVersion"]) && + boundedNonblankString(host.hostId, 128) && boundedNonblankString(host.hostName, 128) && + typeof host.platform === "string" && ["win32", "darwin"].includes(host.platform) && + (host.codexVersion === undefined || boundedNonblankString(host.codexVersion, 64)); } function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } +function snapshotOwnDataRecord(value: unknown): Record | null { + try { + if (!isRecord(value)) return null; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return null; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + const snapshot: Record = {}; + for (const key of keys) { + if (typeof key !== "string") return null; + const descriptor = descriptors[key]; + if (!descriptor || descriptor.enumerable !== true || !("value" in descriptor)) return null; + Object.defineProperty(snapshot, key, { + value: descriptor.value, + enumerable: true, + configurable: true, + writable: true + }); + } + return snapshot; + } catch { + return null; + } +} + +function snapshotOwnDataArray(value: unknown, maximum: number): unknown[] | null { + try { + if (!Array.isArray(value)) return null; + if (Object.getPrototypeOf(value) !== Array.prototype) return null; + const descriptors = Object.getOwnPropertyDescriptors(value) as unknown as Record; + const lengthDescriptor = descriptors.length; + const arrayLength: unknown = lengthDescriptor?.value; + if (!lengthDescriptor || !Object.prototype.hasOwnProperty.call(lengthDescriptor, "value") || + !Number.isSafeInteger(arrayLength) || Number(arrayLength) < 0 || Number(arrayLength) > maximum) return null; + const result: unknown[] = []; + for (let index = 0; index < Number(arrayLength); index++) { + const descriptor = descriptors[String(index)]; + if (!descriptor || descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, "value")) return null; + result.push(descriptor.value); + } + const expectedKeys = new Set(["length", ...result.map((_, index) => String(index))]); + if (Reflect.ownKeys(descriptors).some((key) => typeof key !== "string" || !expectedKeys.has(key))) return null; + return result; + } catch { + return null; + } +} + +function exactOwnDataKeys(value: Record, expected: readonly string[]): boolean { + const keys = Reflect.ownKeys(value); + return keys.length === expected.length && keys.every((key) => + typeof key === "string" && expected.includes(key)); +} + +function onlyAllowedOwnKeys(value: Record, allowed: readonly string[]): boolean { + const keys = Reflect.ownKeys(value); + return keys.every((key) => typeof key === "string" && allowed.includes(key)); +} + function binary(value: unknown): value is 0 | 1 { return value === 0 || value === 1; } +function boundedNonblankString(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= maximum; +} function finitePercent(value: unknown): boolean { return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100; } @@ -309,6 +585,12 @@ function integerIn(value: unknown, minimum: number, maximum: number): value is n function validTimestamp(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; } +function validProtocolTimestamp(value: unknown): number | null { + return positiveBoundedNumber(value) ? value : null; +} +function positiveBoundedNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= Number.MAX_SAFE_INTEGER; +} function compareOwnership(left: RoutedAgentSlot, right: RoutedAgentSlot): number { const ownership = Number(right.ownedByHost === true) - Number(left.ownedByHost === true); diff --git a/src/render.ts b/src/render.ts index e27eb4a..da57424 100644 --- a/src/render.ts +++ b/src/render.ts @@ -2,6 +2,7 @@ import type { AgentVisualStatus, HostHealthState, ThemeMode, UsageWindow, UsageW import { clampPercent, usageLabel } from "./usage.js"; export type BuiltinIconName = "back" | "forward" | "sidebar" | "home" | "navigation"; +type ToggleState = boolean | undefined; export const SIGNAL_COLORS: Record> = { light: { @@ -89,7 +90,7 @@ export function toDataUrl(svg: string): string { return `data:image/svg+xml;charset=utf8,${encodeURIComponent(svg)}`; } -export function renderImportedKeycap(svg: string, theme: ThemeMode = "light"): string { +export function renderImportedKeycap(svg: string, theme: ThemeMode = "light", toggleState?: ToggleState): string { const viewBox = svg.match(/viewBox=["']([^"']+)["']/i)?.[1]; const rootAttributes = svg.match(/]*)>/i)?.[1] ?? ""; const body = svg.match(/]*>([\s\S]*?)<\/svg>/i)?.[1]; @@ -100,7 +101,11 @@ export function renderImportedKeycap(svg: string, theme: ThemeMode = "light"): s if (![minX, minY, width, height].every(Number.isFinite) || width <= 0 || height <= 0) throw new Error("The imported SVG dimensions are invalid."); const surface = SURFACES[theme]; - const glyphColor = theme === "dark" ? "#F2F2EE" : "#24292D"; + const glyphColor = toggleState == null ? (theme === "dark" ? "#F2F2EE" : "#24292D") : "#171C20"; + const toggleSemantic = toggleState == null ? "unknown" : toggleState ? "on" : "off"; + const toggleBackground = toggleState == null + ? undefined + : SIGNAL_COLORS[theme][toggleState ? "complete" : "error"]; const size = 90; const scale = Math.min(size / width, size / height); const x = 27 + (size - width * scale) / 2 - minX * scale; @@ -113,10 +118,11 @@ export function renderImportedKeycap(svg: string, theme: ThemeMode = "light"): s return toDataUrl(` - + + ${toggleBackground ? `` : ""} - ${glyph} + ${glyph} `); } @@ -139,15 +145,21 @@ export function renderBuiltinKeycap(name: BuiltinIconName, theme: ThemeMode = "l `); } -export function renderFallbackKeycap(keycapId: string, theme: ThemeMode = "light"): string { +export function renderFallbackKeycap(keycapId: string, theme: ThemeMode = "light", toggleState?: ToggleState): string { const surface = SURFACES[theme]; const label = escapeXml(keycapId); const fontSize = keycapId.length > 5 ? 17 : 21; + const glyphColor = toggleState == null ? surface.title : "#171C20"; + const toggleSemantic = toggleState == null ? "unknown" : toggleState ? "on" : "off"; + const toggleBackground = toggleState == null + ? undefined + : SIGNAL_COLORS[theme][toggleState ? "complete" : "error"]; return toDataUrl(` - + + ${toggleBackground ? `` : ""} - ${label} + ${label} `); } diff --git a/src/types.ts b/src/types.ts index 3d9ec29..29b85e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,26 @@ export type MicroAgentSlot = { export type MicroActionSlot = "ACT06" | "ACT07" | "ACT08" | "ACT09" | "ACT10_ACT11" | "ACT12"; export type MicroDirection = "up" | "right" | "down" | "left"; export type ReasoningAdjustment = "decrease" | "increase"; +export type ReasoningAdjustmentPolicy = { includeUltra: boolean }; +export type ReasoningAdjustmentResult = "applied" | "blocked-ultra"; +export type ReasoningAdjustmentExecution = { + outcome: ReasoningAdjustmentResult; + reasoningEffort?: string; +}; +export type ModelPresetRequest = { + modelId: string; + reasoningEffort: string; + includeUltra: boolean; +}; +export type ModelPresetExecution = { + modelId: string; + reasoningEffort: string; +}; + +export function isSafeReasoningIdentifier(value: unknown, maxLength = 64): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maxLength && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value); +} export type MicroLayout = { version: 1; @@ -59,8 +79,24 @@ export type UsageSnapshot = { resetCreditsApplicable: number | null; }; +export type CodexModelCatalogEntry = { + modelId: string; + displayName: string; + supportedReasoningEfforts: string[]; +}; + export type MicroSnapshot = { slots: MicroAgentSlot[]; + /** Current reasoning effort selected in the active renderer composer. */ + reasoningEffort?: string; + /** Authoritative active model identity from the visible composer model picker. */ + activeModelId?: string; + /** User-facing name for the authoritative active model. */ + activeModelDisplayName?: string; + /** Complete bounded model catalog associated with the active model authority. */ + modelCatalog?: CodexModelCatalogEntry[]; + /** Whether the active visible Codex reasoning trigger authoritatively reports Fast mode. */ + fastModeEnabled?: boolean; /** Task currently open in the Codex renderer, even when it is outside the six native Micro slots. */ activeThreadKey?: string; /** User-visible title for the active task, including tasks outside the six Micro slots. */ diff --git a/static/imgs/dial.svg b/static/imgs/dial.svg new file mode 100644 index 0000000..4307fdf --- /dev/null +++ b/static/imgs/dial.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/imgs/dial@2x.svg b/static/imgs/dial@2x.svg new file mode 100644 index 0000000..402d834 --- /dev/null +++ b/static/imgs/dial@2x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/layouts/codex-dial.json b/static/layouts/codex-dial.json new file mode 100644 index 0000000..0d26c6d --- /dev/null +++ b/static/layouts/codex-dial.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "com.simeo.codex-deck.codex-dial.layout", + "items": [ + { "key": "accent", "type": "bar", "rect": [8, 88, 184, 6], "value": 100, "range": { "min": 0, "max": 100 }, "bar_bg_c": "#242838", "bar_fill_c": "#7F8CFF", "border_w": 0 }, + { "key": "title", "type": "text", "rect": [10, 5, 180, 20], "value": "CODEX", "font": { "size": 12, "weight": 600 }, "color": "#AEB7FF", "alignment": "center" }, + { "key": "value", "type": "text", "rect": [8, 25, 184, 34], "value": "READY", "font": { "size": 24, "weight": 700 }, "color": "#F4F6FF", "alignment": "center" }, + { "key": "detail", "type": "text", "rect": [8, 61, 184, 20], "value": "TURN OR PRESS", "font": { "size": 11, "weight": 500 }, "color": "#AAB0C2", "alignment": "center" }, + { "key": "indicator", "type": "bar", "rect": [8, 82, 184, 4], "value": 0, "range": { "min": 0, "max": 100 }, "bar_bg_c": "#242838", "bar_fill_c": "#7F8CFF", "border_w": 0 } + ] +} diff --git a/static/manifest.json b/static/manifest.json index c008fc7..3d6f8df 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -70,6 +70,25 @@ { "UUID": "com.simeo.codex-deck.keycap-settings", "Name": "Keycap · Settings", "Tooltip": "Open Codex settings.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.keycap-open-folder", "Name": "Keycap · Open Folder", "Tooltip": "Open a folder in Codex.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, { "UUID": "com.simeo.codex-deck.keycap-add-files", "Name": "Keycap · Add Files", "Tooltip": "Add files to the current composer.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, - { "UUID": "com.simeo.codex-deck.keycap-skills", "Name": "Keycap · Skills", "Tooltip": "Open Codex Skills.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] } + { "UUID": "com.simeo.codex-deck.keycap-skills", "Name": "Keycap · Skills", "Tooltip": "Open Codex Skills.", "Icon": "static/imgs/category-icon", "Controllers": ["Keypad"], "States": [{ "Image": "static/imgs/key", "Title": "" }] }, + { + "UUID": "com.simeo.codex-deck.codex-dial", + "Name": "Codex Dial", + "Tooltip": "Configurable Stream Deck + dial with independent rotation, press, touch, and live Codex feedback.", + "Icon": "static/imgs/category-icon", + "PropertyInspectorPath": "static/property-inspector/codex-dial.html", + "Controllers": ["Encoder"], + "Encoder": { + "Icon": "static/imgs/dial", + "layout": "static/layouts/codex-dial.json", + "StackColor": "#7F8CFF", + "TriggerDescription": { + "Rotate": "Adjust or select", + "Push": "Run the configured press action", + "Touch": "Run the configured touch action" + } + }, + "States": [{ "Image": "static/imgs/key", "Title": "" }] + } ] } diff --git a/static/property-inspector/codex-dial.html b/static/property-inspector/codex-dial.html new file mode 100644 index 0000000..2b5fe56 --- /dev/null +++ b/static/property-inspector/codex-dial.html @@ -0,0 +1,1007 @@ + + + + + + Codex Dial + + + +
+

Codex Dial

+

Configure this knob's rotation, press, touch, and live feedback independently.

+
+ +
+
+

Preset

+ +
+ + + +
+

Rotation

+ + +
+ + +
+ + + + +
+ +
+

Independent gestures

+ + +
+ +
+

Touch-strip feedback

+ + +
+
+ + + + diff --git a/test/controller-dial.test.ts b/test/controller-dial.test.ts new file mode 100644 index 0000000..35bbb8d --- /dev/null +++ b/test/controller-dial.test.ts @@ -0,0 +1,3901 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import streamDeck, { type DialAction, type KeyAction } from "@elgato/streamdeck"; +import { DeckController } from "../src/controller.js"; +import { expandDialPreset, type DialCommandQueue } from "../src/dial-domain.js"; +import type { + CodexDialSettings, DialRuntimeState, ModelPresetEntry, ModelPresetsDialSettings +} from "../src/dial-types.js"; +import type { + CodexHost, HostHealth, MicroSnapshot, ModelPresetExecution, ModelPresetRequest, + ReasoningAdjustmentPolicy, ReasoningAdjustmentExecution, ReasoningAdjustmentResult, RoutedAgentSlot +} from "../src/types.js"; + +type FakeDial = DialAction & { + feedbackCalls: unknown[]; + triggerCalls: unknown[]; + alerts: number; +}; + +type DialRegistrationProbe = { + settings: CodexDialSettings; + state: DialRuntimeState; + queue: DialCommandQueue; +}; + +type ControllerProbe = { + dials: Map; + routedSlots: RoutedAgentSlot[]; + localHost?: CodexHost; + localSnapshot?: { host: CodexHost; observedAt: number; snapshot: MicroSnapshot }; + localHealth: HostHealth; + targetHostId?: string; + targetPlatform: CodexHost["platform"]; + localSnapshotGeneration: number; + committedLocalSnapshotGeneration: number; + modelReasoningMutationPending: number; + relayClient?: { + currentHost(): CodexHost | undefined; + currentHealth(): HostHealth; + currentSnapshot(): { host: CodexHost; observedAt: number; snapshot: MicroSnapshot } | undefined; + supportsCurrentReadyCapability?(capability: string): boolean; + supportsCapabilityForSnapshot?(capability: string, hostId: string, platform: CodexHost["platform"]): boolean; + send(command: unknown): Promise; + }; + microBridge: { + sendAgent(slot: number, act: 0 | 1, threadKey?: string): Promise; + sendAction?(slot: string, act: 0 | 1): Promise; + sendEncoder?(act: 0 | 1): Promise; + sendJoystick?(direction: string, distance: 0 | 1): Promise; + adjustReasoning?( + direction: string, + policy?: ReasoningAdjustmentPolicy + ): Promise; + applyModelPreset?(request: ModelPresetRequest): Promise; + runKeycap?(keycapId: string): Promise; + consumeRateLimitReset?(): Promise; + refresh?(): Promise; + close?(): void; + }; + keycapImages: Map>; + pressedAgents: Map; + dialDescriptionErrors: Set; + dialRenderErrors: Set; + dialSuccessErrors: Set; + refresh(): Promise; + adjustLocalReasoningFromRelay( + direction: "decrease" | "increase", + policy?: ReasoningAdjustmentPolicy + ): Promise; + sendDialToHost( + route: { kind: "host"; hostId?: string; platform: CodexHost["platform"] }, + command: unknown, + local: () => Promise, + requireReady?: boolean + ): Promise; + refreshInFlight?: Promise; + refreshLocalUsage(): Promise; + renderAll(): Promise; + renderMicroAction(registration: { action: KeyAction; slot: "ACT06" }): Promise; + renderFixedAction(registration: { + action: KeyAction; + source: { kind: "local"; keycapId: string }; + }): Promise; + catalogRevision: number; +}; + +const HOST: CodexHost = { + hostId: "host-a", + hostName: "Mac", + platform: "darwin" +}; + +const REMOTE_HOST: CodexHost = { + hostId: "host-b", + hostName: "Windows", + platform: "win32" +}; + +const SNAPSHOT: MicroSnapshot = { + slots: [], + layout: { + version: 1, + slots: { + ACT06: { keycapId: "APPR" }, ACT07: { keycapId: "REJ" }, + ACT08: { keycapId: "SPLIT" }, ACT09: { keycapId: "MIC" }, + ACT10_ACT11: { keycapId: "CODEX" }, ACT12: { keycapId: "SETUP" } + }, + analogStick: { up: {}, right: {}, down: {}, left: {} } + }, + agentSource: "recent", + lightingAutoOff: "off", + theme: "dark", + usage: { + windows: [{ + id: "five-hour", kind: "five-hour", usedPercent: 20, remainingPercent: 80, + windowDurationMins: 300, resetsAt: 50_000 + }], + observedAt: 1_000, + resetCreditsAvailable: 1, + resetCreditsApplicable: 1 + } +}; + +function fakeDial( + id: string, + options: { + rejectDescriptions?: boolean; + rejectSuccessFeedback?: boolean; + rejectUltraFeedback?: boolean; + descriptionError?: string; + } = {} +): FakeDial { + const dial = { + id, + feedbackCalls: [] as unknown[], + triggerCalls: [] as unknown[], + alerts: 0, + async setFeedback(payload: unknown) { + this.feedbackCalls.push(payload); + if (options.rejectSuccessFeedback && JSON.stringify(payload).includes("RESET COMPLETE")) { + throw new Error("success feedback unavailable"); + } + if (options.rejectUltraFeedback && JSON.stringify(payload).includes("ULTRA OFF")) { + throw new Error("Ultra feedback unavailable"); + } + }, + async setTriggerDescription(payload: unknown) { + this.triggerCalls.push(payload); + if (options.descriptionError) throw new Error(options.descriptionError); + if (options.rejectDescriptions) throw new Error("description unavailable"); + }, + async showAlert() { this.alerts += 1; } + }; + return dial as unknown as FakeDial; +} + +type FakeKey = KeyAction & { images: string[]; titles: string[] }; + +function fakeKey(id: string): FakeKey { + const images: string[] = []; + const titles: string[] = []; + return { + id, + images, + titles, + async setImage(image: string) { images.push(image); }, + async setTitle(title: string) { titles.push(title); } + } as unknown as FakeKey; +} + +function snapshotWithFast(fastModeEnabled: boolean | undefined, act06 = "FAST"): MicroSnapshot { + const snapshot: MicroSnapshot = { + ...SNAPSHOT, + layout: { + ...SNAPSHOT.layout, + slots: { + ...SNAPSHOT.layout.slots, + ACT06: { keycapId: act06 } + } + } + }; + if (fastModeEnabled !== undefined) snapshot.fastModeEnabled = fastModeEnabled; + return snapshot; +} + +const MODEL_CATALOG = [ + { + modelId: "gpt-5.6-sol", displayName: "5.6 Sol", + supportedReasoningEfforts: ["medium", "high"] + }, + { + modelId: "gpt-5.6-terra", displayName: "5.6 Terra", + supportedReasoningEfforts: ["medium"] + } +]; + +function modelSnapshot(modelId = "gpt-5.6-sol", reasoningEffort = "high"): MicroSnapshot { + const activeModelDisplayName = modelId === "gpt-5.6-terra" ? "5.6 Terra" : "5.6 Sol"; + return { + ...structuredClone(SNAPSHOT), + activeModelId: modelId, + activeModelDisplayName, + reasoningEffort, + modelCatalog: structuredClone(MODEL_CATALOG) + }; +} + +function modelPresetSettings(entries: ModelPresetEntry[] = [ + { modelId: "gpt-5.6-sol", reasoningEffort: "high" }, + { modelId: "gpt-5.6-sol", reasoningEffort: "medium" }, + { modelId: "gpt-5.6-terra", reasoningEffort: "medium" } +]): ModelPresetsDialSettings { + return { ...expandDialPreset("model-presets"), modelPresets: entries } as ModelPresetsDialSettings; +} + +function decodeImage(image: string): string { + return decodeURIComponent(image.replace(/^data:image\/svg\+xml;charset=utf8,/, "")); +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function probe(controller: DeckController): ControllerProbe { + return controller as unknown as ControllerProbe; +} + +async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} + +async function idle(controller: DeckController, actionId: string): Promise { + await probe(controller).dials.get(actionId)!.queue.idle(); + await settle(); +} + +function routedAgent(id: number, threadKey: string, host = HOST): RoutedAgentSlot { + return { + id, + sourceSlot: id, + threadKey, + title: `Task ${threadKey}`, + status: "idle", + selected: false, + observedAt: 1_000, + host + }; +} + +test("registration normalizes settings, caches feedback, and survives description failures", async () => { + const controller = new DeckController(); + const action = fakeDial("normalized", { rejectDescriptions: true }); + + controller.registerDial(action, { shell: "rm" }); + await settle(); + assert.deepEqual(probe(controller).dials.get(action.id)!.settings, expandDialPreset("reasoning")); + assert.equal(action.feedbackCalls.length, 1, "description failure must not block feedback"); + + controller.updateDialSettings(action, { shell: "rm" }); + await settle(); + assert.deepEqual(probe(controller).dials.get(action.id)!.settings, expandDialPreset("reasoning")); + assert.equal(action.feedbackCalls.length, 1, "unchanged feedback is not rewritten"); +}); + +test("only effective FAST keycaps render live state and state changes replace cached images", async () => { + const controller = new DeckController(); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(true) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + const microFast = fakeKey("micro-fast"); + const microOther = fakeKey("micro-other"); + const officialFast = fakeKey("official-fast"); + const officialOther = fakeKey("official-other"); + + controller.registerMicroAction("ACT06", microFast); + controller.registerMicroAction("ACT07", microOther); + controller.registerFixedAction("FAST", officialFast, { kind: "local", keycapId: "FAST" }); + controller.registerFixedAction("APPR", officialOther, { kind: "local", keycapId: "APPR" }); + await state.renderAll(); + + assert.match(decodeImage(microFast.images.at(-1)!), /data-toggle-state="on"/); + assert.match(decodeImage(officialFast.images.at(-1)!), /data-toggle-state="on"/); + assert.match(decodeImage(microOther.images.at(-1)!), /data-toggle-state="unknown"/); + assert.match(decodeImage(officialOther.images.at(-1)!), /data-toggle-state="unknown"/); + + const priorMicroImage = microFast.images.at(-1)!; + const priorOfficialImage = officialFast.images.at(-1)!; + state.localSnapshot = { host: HOST, observedAt: 2_000, snapshot: snapshotWithFast(false) }; + await state.renderAll(); + + assert.match(decodeImage(microFast.images.at(-1)!), /data-toggle-state="off"/); + assert.match(decodeImage(officialFast.images.at(-1)!), /data-toggle-state="off"/); + assert.notEqual(microFast.images.at(-1), priorMicroImage, "Micro FAST setImage changes with authoritative state"); + assert.notEqual(officialFast.images.at(-1), priorOfficialImage, "official FAST setImage changes with authoritative state"); + + state.localSnapshot = { host: HOST, observedAt: 3_000, snapshot: snapshotWithFast(true, "APPR") }; + await state.renderAll(); + assert.match(decodeImage(microFast.images.at(-1)!), /data-toggle-state="unknown"/); + assert.match(decodeImage(officialFast.images.at(-1)!), /data-toggle-state="on"/); +}); + +test("a delayed Micro FAST render cannot overwrite newer authoritative feedback", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeKey("ordered-micro-fast"); + const delayedOn = deferred(); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(true) }; + state.keycapImages.set("dark:FAST:on", delayedOn.promise); + state.keycapImages.set("dark:FAST:off", Promise.resolve("fast-off")); + + const olderRender = state.renderMicroAction({ action, slot: "ACT06" }); + state.localSnapshot = { host: HOST, observedAt: 2_000, snapshot: snapshotWithFast(false) }; + await state.renderMicroAction({ action, slot: "ACT06" }); + assert.deepEqual(action.images, ["fast-off"]); + + delayedOn.resolve("fast-on"); + await olderRender; + assert.deepEqual(action.images, ["fast-off"], "the obsolete ON render is discarded"); +}); + +test("a delayed fixed FAST render cannot overwrite newer authoritative feedback", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeKey("ordered-fixed-fast"); + const delayedOn = deferred(); + const registration = { action, source: { kind: "local" as const, keycapId: "FAST" } }; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(true) }; + state.keycapImages.set("dark:FAST:on", delayedOn.promise); + state.keycapImages.set("dark:FAST:off", Promise.resolve("fast-off")); + + const olderRender = state.renderFixedAction(registration); + state.localSnapshot = { host: HOST, observedAt: 2_000, snapshot: snapshotWithFast(false) }; + await state.renderFixedAction(registration); + assert.deepEqual(action.images, ["fast-off"]); + + delayedOn.resolve("fast-on"); + await olderRender; + assert.deepEqual(action.images, ["fast-off"], "the obsolete fixed ON render is discarded"); +}); + +test("successful local FAST activation refreshes immediately without refreshing releases or other actions", async () => { + const controller = new DeckController(); + const state = probe(controller); + const commands: string[] = []; + let refreshes = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(false) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async sendAction(slot, act) { commands.push(`action:${slot}:${act}`); }, + async runKeycap(keycapId) { commands.push(`keycap:${keycapId}`); } + }; + state.refresh = async () => { refreshes += 1; }; + + await controller.sendMicroAction("ACT06", 1); + await controller.sendMicroAction("ACT06", 0); + await controller.sendMicroAction("ACT07", 1); + await controller.runKeycap("FAST"); + await controller.runKeycap("APPR"); + + assert.deepEqual(commands, [ + "action:ACT06:1", "action:ACT06:0", "action:ACT07:1", "keycap:FAST", "keycap:APPR" + ]); + assert.equal(refreshes, 2, "only the local Micro FAST down and official FAST activation refresh"); + assert.equal(state.localSnapshot.snapshot.fastModeEnabled, false, "commands never optimistically flip state"); +}); + +test("remote, failed, and failed-refresh FAST commands never synthesize local state", async () => { + const controller = new DeckController(); + const state = probe(controller); + const remoteCommands: unknown[] = []; + let refreshes = 0; + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(false) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async sendAction() { throw new Error("local FAST failed"); }, + async runKeycap() { throw new Error("local FAST failed"); } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + async send(command) { remoteCommands.push(command); } + }; + state.refresh = async () => { refreshes += 1; }; + + await controller.sendMicroAction("ACT06", 1); + await controller.runKeycap("FAST"); + assert.equal(refreshes, 0, "remote relay commands own their snapshot barrier"); + + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + await assert.rejects(controller.sendMicroAction("ACT06", 1), /local FAST failed/); + await assert.rejects(controller.runKeycap("FAST"), /local FAST failed/); + assert.equal(refreshes, 0, "failed commands do not refresh"); + assert.equal(state.localSnapshot.snapshot.fastModeEnabled, false); + + state.microBridge = { async sendAgent() {}, async runKeycap() {} }; + state.refresh = async () => { throw new Error("authoritative refresh failed"); }; + await assert.rejects(controller.runKeycap("FAST"), /authoritative refresh failed/); + assert.equal(state.localSnapshot.snapshot.fastModeEnabled, false, "failed refresh preserves last authoritative value"); + assert.equal(remoteCommands.length, 2); +}); + +test("local dial touch FAST uses the command path and refreshes its authoritative state", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("touch-fast-refresh"); + const keycaps: string[] = []; + let refreshes = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(false) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async runKeycap(keycapId) { keycaps.push(keycapId); } + }; + state.refresh = async () => { refreshes += 1; }; + + controller.registerDial(action, { ...expandDialPreset("custom"), touchTap: "keycap.FAST" }); + await controller.touchDial(action); + + assert.deepEqual(keycaps, ["FAST"]); + assert.equal(refreshes, 1); + assert.equal(state.localSnapshot.snapshot.fastModeEnabled, false); +}); + +test("local FAST activation queues a post-command snapshot behind an older refresh", async () => { + const controller = new DeckController(); + const state = probe(controller); + const keycaps: string[] = []; + let postCommandRefreshes = 0; + let releaseOlderRefresh!: () => void; + const olderRefresh = new Promise((resolve) => { releaseOlderRefresh = resolve; }); + const trackedOlderRefresh = olderRefresh.finally(() => { state.refreshInFlight = undefined; }); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(false) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async runKeycap(keycapId) { keycaps.push(keycapId); } + }; + state.refreshInFlight = trackedOlderRefresh; + state.refresh = async () => { + if (state.refreshInFlight) return state.refreshInFlight; + postCommandRefreshes += 1; + }; + + const activation = controller.runKeycap("FAST"); + await settle(); + assert.deepEqual(keycaps, ["FAST"]); + assert.equal(postCommandRefreshes, 0, "the activation first lets the older snapshot finish"); + + releaseOlderRefresh(); + await activation; + assert.equal(postCommandRefreshes, 1, "a new authoritative read starts after the command"); +}); + +test("local FAST activation refreshes after an older refresh rejects", async () => { + const controller = new DeckController(); + const state = probe(controller); + let postCommandRefreshes = 0; + let rejectOlderRefresh!: (error: Error) => void; + const olderRefresh = new Promise((_resolve, reject) => { rejectOlderRefresh = reject; }) + .finally(() => { state.refreshInFlight = undefined; }); + void olderRefresh.catch(() => {}); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(false) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { async sendAgent() {}, async runKeycap() {} }; + state.refreshInFlight = olderRefresh; + state.refresh = async () => { + if (state.refreshInFlight) return state.refreshInFlight; + postCommandRefreshes += 1; + }; + + const activation = controller.runKeycap("FAST"); + await settle(); + assert.equal(postCommandRefreshes, 0); + rejectOlderRefresh(new Error("older render failed")); + await assert.doesNotReject(activation); + assert.equal(postCommandRefreshes, 1, "the rejected older read cannot suppress the post-command read"); +}); + +test("concurrent FAST activations do not refresh after shutdown while awaiting an older refresh", async () => { + const controller = new DeckController(); + const state = probe(controller); + const olderRefresh = deferred(); + const trackedOlderRefresh = olderRefresh.promise.finally(() => { state.refreshInFlight = undefined; }); + let commands = 0; + let refreshes = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: snapshotWithFast(false) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async runKeycap() { commands += 1; }, + async refresh() { + refreshes += 1; + return snapshotWithFast(true); + }, + close() {} + }; + state.refreshInFlight = trackedOlderRefresh; + + const activations = [controller.runKeycap("FAST"), controller.runKeycap("FAST")]; + await settle(); + assert.equal(commands, 2); + assert.equal(refreshes, 0); + + controller.stop(); + olderRefresh.resolve(); + await Promise.all(activations); + assert.equal(refreshes, 0, "shutdown prevents every queued post-command refresh"); +}); + +test("selector rotation previews only and selector state stays isolated per action", async () => { + const controller = new DeckController(); + const first = fakeDial("usage-first"); + const second = fakeDial("usage-second"); + let refreshes = 0; + (controller as unknown as { refreshUsage(): Promise }).refreshUsage = async () => { refreshes += 1; }; + + controller.registerDial(first, expandDialPreset("usage")); + controller.registerDial(second, expandDialPreset("usage")); + controller.rotateDial(first, 1); + await idle(controller, first.id); + + assert.equal(refreshes, 0, "selector rotation must not execute an action"); + assert.equal(probe(controller).dials.get(first.id)!.state.usageMode, "five-hour"); + assert.equal(probe(controller).dials.get(second.id)!.state.usageMode, "auto"); + + await controller.beginDialPress(first); + assert.equal(probe(controller).dials.get(first.id)!.state.usageOverview, true); + assert.equal(probe(controller).dials.get(second.id)!.state.usageOverview, false); +}); + +test("agent selector release uses the identity resolved on dial down", async () => { + const controller = new DeckController(); + const action = fakeDial("agent-selector"); + const calls: Array<[number, 0 | 1, string | undefined]> = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.routedSlots = [routedAgent(0, "thread-a"), routedAgent(1, "thread-b")]; + state.microBridge = { + async sendAgent(slot, act, threadKey) { calls.push([slot, act, threadKey]); } + }; + + controller.registerDial(action, expandDialPreset("agents")); + await controller.beginDialPress(action); + controller.rotateDial(action, 1); + await idle(controller, action.id); + await controller.finishDialPress(action); + + assert.deepEqual(calls, [ + [0, 1, "thread-a"], + [0, 0, "thread-a"] + ]); +}); + +test("agent selector accepts merged-list reorder while retaining its captured owner", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("agent-reorder"); + const calls: Array<[number, 0 | 1, string | undefined]> = []; + let releaseBacklog!: () => void; + const backlog = new Promise((resolve) => { releaseBacklog = resolve; }); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.routedSlots = [routedAgent(0, "thread-a"), routedAgent(1, "thread-b")]; + state.microBridge = { + async sendAgent(slot, act, threadKey) { calls.push([slot, act, threadKey]); } + }; + controller.registerDial(action, expandDialPreset("agents")); + probe(controller).dials.get(action.id)!.queue.enqueue(() => backlog); + const down = controller.beginDialPress(action); + const second = routedAgent(0, "thread-b"); + const first = routedAgent(1, "thread-a"); + first.sourceSlot = 0; + state.routedSlots = [second, first]; + releaseBacklog(); + await down; + await controller.finishDialPress(action); + + assert.deepEqual(calls, [[0, 1, "thread-a"], [0, 0, "thread-a"]]); + assert.equal(action.alerts, 0); +}); + +test("paired detents continue after a dispatch failure and alert only the failing command", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-errors"); + const attempts: string[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { + attempts.push(`attempt-${attempts.length + 1}`); + if (attempts.length === 1) throw new Error("first detent failed"); + return { outcome: "applied", reasoningEffort: "high" }; + } + }; + + controller.registerDial(action, expandDialPreset("reasoning")); + controller.rotateDial(action, 2); + await idle(controller, action.id); + + assert.deepEqual(attempts, ["attempt-1", "attempt-2"]); + assert.equal(action.alerts, 1); +}); + +test("reasoning dial maps each direction to one dedicated adjustment without encoder clicks", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-direction"); + const adjustments: string[] = []; + const encoderClicks: number[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning(direction) { + adjustments.push(direction); + return { outcome: "applied", reasoningEffort: "high" }; + }, + async sendEncoder(act) { encoderClicks.push(act); } + }; + + controller.registerDial(action, expandDialPreset("reasoning")); + controller.rotateDial(action, 1); + controller.rotateDial(action, -1); + await idle(controller, action.id); + + assert.deepEqual(adjustments, ["increase", "decrease"]); + assert.deepEqual(encoderClicks, []); +}); + +test("local reasoning detents capture and pass each dial's explicit Ultra policy", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-local-policy"); + const policies: Array<[string, ReasoningAdjustmentPolicy | undefined]> = []; + const backlog = deferred(); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning(direction, policy) { + policies.push([direction, policy]); + return { outcome: "applied", reasoningEffort: "high" }; + } + }; + + controller.registerDial(action, { ...expandDialPreset("reasoning"), includeUltraReasoning: false }); + probe(controller).dials.get(action.id)!.queue.enqueue(() => backlog.promise); + controller.rotateDial(action, 1); + controller.updateDialSettings(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: true + }); + backlog.resolve(); + await idle(controller, action.id); + controller.rotateDial(action, -1); + await idle(controller, action.id); + + assert.deepEqual(policies, [ + ["increase", { includeUltra: false }], + ["decrease", { includeUltra: true }] + ]); +}); + +test("local dial outcome extraction ignores malformed structured executions", async () => { + const controller = new DeckController(); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + + const outcome = await state.sendDialToHost( + { kind: "host", hostId: HOST.hostId, platform: HOST.platform }, + { kind: "reasoning", direction: "increase", includeUltra: true }, + async () => ({ outcome: "unexpected" }), + false + ); + + assert.equal(outcome, undefined); +}); + +test("local confirmed reasoning renders immediately and supersedes an older refresh", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-local-immediate"); + const state = probe(controller); + const staleRefresh = deferred(); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async refresh() { return staleRefresh.promise; }, + async adjustReasoning() { + return { outcome: "applied", reasoningEffort: "xhigh" }; + } + }; + controller.registerDial(action, expandDialPreset("reasoning")); + await settle(); + const originalSnapshot = state.localSnapshot; + const originalObservedAt = originalSnapshot.observedAt; + const older = state.refresh(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + const immediateValue = (action.feedbackCalls.at(-1) as { value: string }).value; + const confirmedSnapshot = state.localSnapshot; + const confirmedGeneration = state.localSnapshotGeneration; + + staleRefresh.resolve({ ...structuredClone(SNAPSHOT), reasoningEffort: "medium" }); + await older; + await settle(); + + assert.equal(immediateValue, "XHIGH", "feedback must not wait for the 1.2 second poll"); + assert.equal(confirmedGeneration, 2, "the older poll and confirmed mutation own distinct generations"); + assert.equal(state.committedLocalSnapshotGeneration, 2); + assert.notEqual(confirmedSnapshot, originalSnapshot); + assert.notEqual(confirmedSnapshot.snapshot, originalSnapshot.snapshot); + assert.equal(confirmedSnapshot.observedAt, originalObservedAt); + assert.equal(state.localSnapshot, confirmedSnapshot); + assert.equal(state.localSnapshot.snapshot.reasoningEffort, "xhigh"); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "XHIGH"); + controller.unregisterDial(action); +}); + +test("local confirmed reasoning survives same-host identity object replacement", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-local-host-object-replacement"); + const state = probe(controller); + const capturedHost = { ...HOST }; + state.localHost = capturedHost; + state.targetHostId = capturedHost.hostId; + state.targetPlatform = capturedHost.platform; + state.localSnapshot = { + host: capturedHost, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { + const refreshedHost = { ...HOST }; + state.localHost = refreshedHost; + state.localSnapshot = { + host: refreshedHost, observedAt: 2_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + return { outcome: "applied", reasoningEffort: "xhigh" }; + } + }; + controller.registerDial(action, expandDialPreset("reasoning")); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + + assert.notEqual(state.localHost, capturedHost); + assert.equal(state.localHost?.hostId, capturedHost.hostId); + assert.equal(state.localSnapshotGeneration, 1); + assert.equal(state.localSnapshot?.snapshot.reasoningEffort, "xhigh"); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "XHIGH"); + controller.unregisterDial(action); +}); + +test("local confirmed reasoning never patches a genuinely replaced host", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-local-different-host"); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { + const replacementHost = { + ...HOST, + hostId: "host-replacement" + }; + state.localHost = replacementHost; + state.localSnapshot = { + host: replacementHost, observedAt: 2_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "medium" } + }; + return { outcome: "applied", reasoningEffort: "xhigh" }; + } + }; + controller.registerDial(action, expandDialPreset("reasoning")); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + + assert.equal(state.localSnapshotGeneration, 1, "the pre-operation fence survives a host replacement"); + assert.equal(state.localSnapshot?.host.hostId, "host-replacement"); + assert.equal(state.localSnapshot?.snapshot.reasoningEffort, "medium"); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "MEDIUM"); + controller.unregisterDial(action); +}); + +test("local reasoning feedback accepts only exact own bounded execution data", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-local-invalid-feedback"); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + let getterReads = 0; + const accessor = { outcome: "applied" }; + Object.defineProperty(accessor, "reasoningEffort", { + enumerable: true, + get() { getterReads += 1; return "xhigh"; } + }); + const invalid = [ + { outcome: "applied", reasoningEffort: "" }, + { outcome: "applied", reasoningEffort: " high " }, + { outcome: "applied", reasoningEffort: "x high" }, + { outcome: "applied", reasoningEffort: "\n" }, + { outcome: "applied", reasoningEffort: "high\n" }, + { outcome: "applied", reasoningEffort: "\u0000" }, + { outcome: "applied", reasoningEffort: "!!!" }, + { outcome: "applied", reasoningEffort: "é" }, + { outcome: "applied", reasoningEffort: "推理" }, + { outcome: "applied", reasoningEffort: "x".repeat(65) }, + { outcome: "applied", reasoningEffort: 1 }, + { outcome: "applied", reasoningEffort: "xhigh", extra: true }, + { outcome: "applied", reasoningEffort: "xhigh", [Symbol("extra")]: true }, + accessor + ]; + + controller.registerDial(action, expandDialPreset("reasoning")); + await settle(); + + for (const execution of invalid) { + const result = await state.sendDialToHost( + { kind: "host", hostId: HOST.hostId, platform: HOST.platform }, + { + kind: "reasoning", direction: "increase", includeUltra: true, + includeReasoningFeedback: true + }, + async () => execution as ReasoningAdjustmentExecution, + false + ); + assert.equal(result, undefined); + assert.equal(state.localSnapshot.snapshot.reasoningEffort, "high"); + await state.renderAll(); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "HIGH"); + } + assert.equal(getterReads, 0); + controller.unregisterDial(action); +}); + +test("remote reasoning detents carry each dial's explicit Ultra policy", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-remote-policy"); + const commands: unknown[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + supportsCurrentReadyCapability: () => true, + async send(command) { commands.push(command); return "applied"; } + }; + + controller.registerDial(action, { ...expandDialPreset("reasoning"), includeUltraReasoning: false }); + controller.rotateDial(action, 1); + await idle(controller, action.id); + controller.updateDialSettings(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: true + }); + controller.rotateDial(action, -1); + await idle(controller, action.id); + + assert.deepEqual(commands, [ + { + kind: "reasoning", direction: "increase", includeUltra: false, + includeReasoningFeedback: true + }, + { + kind: "reasoning", direction: "decrease", includeUltra: true, + includeReasoningFeedback: true + } + ]); +}); + +test("remote confirmed reasoning rerenders the same live registration immediately", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-remote-immediate"); + const state = probe(controller); + let remoteSnapshot = { + host: REMOTE_HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + const commands: unknown[] = []; + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => remoteSnapshot, + supportsCurrentReadyCapability: () => true, + async send(command) { + commands.push(command); + remoteSnapshot = { + ...remoteSnapshot, + snapshot: { ...remoteSnapshot.snapshot, reasoningEffort: "xhigh" } + }; + return { outcome: "applied", reasoningEffort: "xhigh" }; + } + }; + controller.registerDial(action, expandDialPreset("reasoning")); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + + assert.deepEqual(commands, [{ + kind: "reasoning", direction: "increase", includeUltra: false, + includeReasoningFeedback: true + }]); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "XHIGH"); + controller.unregisterDial(action); +}); + +test("remote reasoning result never renders a replaced or disposed registration", async () => { + for (const lifecycle of ["replaced", "disposed"] as const) { + const controller = new DeckController(); + const oldAction = fakeDial(`reasoning-remote-stale-${lifecycle}`); + const replacement = fakeDial(oldAction.id); + const state = probe(controller); + const response = deferred(); + let remoteSnapshot = { + host: REMOTE_HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => remoteSnapshot, + supportsCurrentReadyCapability: () => true, + async send() { + await response.promise; + remoteSnapshot = { + ...remoteSnapshot, + snapshot: { ...remoteSnapshot.snapshot, reasoningEffort: "xhigh" } + }; + return { outcome: "applied", reasoningEffort: "xhigh" }; + } + }; + controller.registerDial(oldAction, expandDialPreset("reasoning")); + await settle(); + const oldRegistration = probe(controller).dials.get(oldAction.id)!; + controller.rotateDial(oldAction, 1); + await settle(); + if (lifecycle === "replaced") { + controller.registerDial(replacement, expandDialPreset("reasoning")); + await settle(); + } else controller.unregisterDial(oldAction); + const oldCalls = oldAction.feedbackCalls.length; + const replacementCalls = replacement.feedbackCalls.length; + response.resolve(); + await oldRegistration.queue.idle(); + await settle(); + + assert.equal(oldAction.feedbackCalls.length, oldCalls, lifecycle); + assert.equal(replacement.feedbackCalls.length, replacementCalls, lifecycle); + if (lifecycle === "replaced") controller.unregisterDial(replacement); + } +}); + +test("rapid remote reasoning detents render confirmed efforts in command order", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-remote-ordered"); + const state = probe(controller); + const efforts = ["medium", "high", "xhigh"]; + let remoteSnapshot = { + host: REMOTE_HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "low" } + }; + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => remoteSnapshot, + supportsCurrentReadyCapability: () => true, + async send() { + const reasoningEffort = efforts.shift()!; + remoteSnapshot = { + ...remoteSnapshot, + snapshot: { ...remoteSnapshot.snapshot, reasoningEffort } + }; + return { outcome: "applied", reasoningEffort }; + } + }; + controller.registerDial(action, expandDialPreset("reasoning")); + await settle(); + controller.rotateDial(action, 3); + await idle(controller, action.id); + + assert.deepEqual( + action.feedbackCalls.map((call) => (call as { value: string }).value), + ["LOW", "MEDIUM", "HIGH", "XHIGH"] + ); + controller.unregisterDial(action); +}); + +test("remote missing, malformed, and failed feedback never invent a reasoning transition", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-remote-invalid-feedback"); + const state = probe(controller); + const results: Array = [ + "applied", + { outcome: "applied", reasoningEffort: "" }, + new Error("remote adjustment failed") + ]; + const remoteSnapshot = { + host: REMOTE_HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => remoteSnapshot, + supportsCurrentReadyCapability: () => true, + async send() { + const result = results.shift()!; + if (result instanceof Error) throw result; + return result; + } + }; + controller.registerDial(action, expandDialPreset("reasoning")); + await settle(); + controller.rotateDial(action, 3); + await idle(controller, action.id); + + assert.deepEqual( + action.feedbackCalls.map((call) => (call as { value: string }).value), + ["HIGH"] + ); + assert.equal(action.alerts, 1); + assert.equal(remoteSnapshot.snapshot.reasoningEffort, "high"); + controller.unregisterDial(action); +}); + +test("restricted remote reasoning refuses a legacy peer before controller send", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-legacy-policy"); + const commands: unknown[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + supportsCurrentReadyCapability: () => false, + async send(command) { commands.push(command); return "applied"; } + }; + controller.registerDial(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: false + }); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + + assert.deepEqual(commands, []); + assert.equal(action.alerts, 1); + controller.unregisterDial(action); +}); + +test("public reasoning actions remain unrestricted locally and remotely", async () => { + const controller = new DeckController(); + const localPolicies: Array<[string, ReasoningAdjustmentPolicy | undefined]> = []; + const remoteCommands: unknown[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning(direction, policy) { + localPolicies.push([direction, policy]); + return { outcome: "applied", reasoningEffort: "high" }; + } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + async send(command) { remoteCommands.push(command); return "applied"; } + }; + + await controller.adjustReasoning("increase"); + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + await controller.adjustReasoning("decrease"); + + assert.deepEqual(localPolicies, [["increase", { includeUltra: true }]]); + assert.deepEqual(remoteCommands, [ + { kind: "reasoning", direction: "decrease", includeUltra: true } + ]); +}); + +test("reasoning keycaps route through guarded public reasoning locally and remotely", async () => { + const controller = new DeckController(); + const localPolicies: Array<[string, ReasoningAdjustmentPolicy | undefined]> = []; + const directKeycaps: string[] = []; + const remoteCommands: unknown[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning(direction, policy) { + localPolicies.push([direction, policy]); + return { outcome: "applied", reasoningEffort: "high" }; + }, + async runKeycap(keycapId) { directKeycaps.push(keycapId); } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + async send(command) { remoteCommands.push(command); return "applied"; } + }; + + await controller.runKeycap("MIND+"); + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + await controller.runKeycap("MIND-"); + + assert.deepEqual(localPolicies, [["increase", { includeUltra: true }]]); + assert.deepEqual(remoteCommands, [ + { kind: "reasoning", direction: "decrease", includeUltra: true } + ]); + assert.deepEqual(directKeycaps, []); +}); + +test("dial reasoning keycaps use guarded reasoning with the dial Ultra policy", async () => { + const controller = new DeckController(); + const action = fakeDial("guarded-reasoning-keycaps"); + const adjustments: Array<[string, ReasoningAdjustmentPolicy | undefined]> = []; + const directKeycaps: string[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning(direction, policy) { + adjustments.push([direction, policy]); + return { + outcome: "applied", + reasoningEffort: direction === "increase" ? "high" : "medium" + }; + }, + async runKeycap(keycapId) { directKeycaps.push(keycapId); } + }; + controller.registerDial(action, { + ...expandDialPreset("custom"), + includeUltraReasoning: false, + rotation: { + kind: "paired", + counterClockwise: "keycap.MIND-", + clockwise: "keycap.MIND+" + } + }); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + controller.rotateDial(action, -1); + await idle(controller, action.id); + + assert.deepEqual(adjustments, [ + ["increase", { includeUltra: false }], + ["decrease", { includeUltra: false }] + ]); + assert.deepEqual(directKeycaps, []); + controller.unregisterDial(action); +}); + +test("blocked local and remote reasoning show identical registration-safe Ultra feedback without alerts", async () => { + const expected = { + title: "REASONING", + value: "ULTRA OFF", + detail: "ENABLE IN DIAL SETTINGS", + indicator: 100, + accent: { value: 100, bar_fill_c: "#FF9A3D" } + }; + for (const route of ["local", "remote"] as const) { + const controller = new DeckController(); + const action = fakeDial(`reasoning-blocked-${route}`); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = route === "local" ? HOST.hostId : REMOTE_HOST.hostId; + state.targetPlatform = route === "local" ? HOST.platform : REMOTE_HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + let remoteSnapshot = { + host: REMOTE_HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { + return { outcome: "blocked-ultra", reasoningEffort: "max" }; + } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => remoteSnapshot, + supportsCurrentReadyCapability: () => true, + async send() { + remoteSnapshot = { + ...remoteSnapshot, + snapshot: { ...remoteSnapshot.snapshot, reasoningEffort: "max" } + }; + return { outcome: "blocked-ultra", reasoningEffort: "max" }; + } + }; + controller.registerDial(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: false + }); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + + assert.deepEqual(action.feedbackCalls.at(-1), expected, route); + assert.equal( + route === "local" + ? state.localSnapshot?.snapshot.reasoningEffort + : state.relayClient!.currentSnapshot()?.snapshot.reasoningEffort, + "max", + `${route} authoritative feedback is retained behind the notice` + ); + assert.equal(action.alerts, 0, route); + controller.unregisterDial(action); + } +}); + +test("Ultra notice serializes behind an in-flight authoritative render for its full duration", async (t) => { + const controller = new DeckController(); + const state = probe(controller); + const highStarted = deferred(); + const releaseHigh = deferred(); + const blockedReturned = deferred(); + const completedWrites: string[] = []; + const feedbackCalls: unknown[] = []; + let blockHigh = true; + const action = { + id: "reasoning-blocked-render-race", + feedbackCalls, + triggerCalls: [], + alerts: 0, + async setFeedback(payload: unknown) { + const value = String((payload as { value?: unknown }).value); + feedbackCalls.push(payload); + if (value === "HIGH" && blockHigh) { + blockHigh = false; + highStarted.resolve(); + await releaseHigh.promise; + } + completedWrites.push(value); + }, + async setTriggerDescription() {}, + async showAlert(this: { alerts: number }) { this.alerts += 1; } + } as unknown as FakeDial; + t.after(() => controller.unregisterDial(action)); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { + blockedReturned.resolve(); + return { outcome: "blocked-ultra" }; + } + }; + + controller.registerDial(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: false + }); + await highStarted.promise; + controller.rotateDial(action, 1); + await blockedReturned.promise; + await settle(); + releaseHigh.resolve(); + await idle(controller, action.id); + + assert.deepEqual(completedWrites, ["HIGH", "ULTRA OFF"]); + await new Promise((resolve) => setTimeout(resolve, 1_150)); + assert.equal(completedWrites.at(-1), "ULTRA OFF"); + await new Promise((resolve) => setTimeout(resolve, 100)); + await settle(); + assert.equal(completedWrites.at(-1), "HIGH"); + assert.equal(action.alerts, 0); +}); + +test("blocked reasoning restores the latest authoritative feedback after 1.2 seconds", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-blocked-restore"); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "medium" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return { outcome: "blocked-ultra" }; } + }; + controller.registerDial(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: false + }); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "ULTRA OFF"); + state.localSnapshot = { + host: HOST, observedAt: 2_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "xhigh" } + }; + await state.renderAll(); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "ULTRA OFF"); + + await new Promise((resolve) => setTimeout(resolve, 1_250)); + await settle(); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "XHIGH"); + controller.unregisterDial(action); +}); + +test("Ultra notices are canceled safely by settings changes, disposal, and registration replacement", async () => { + const controller = new DeckController(); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return { outcome: "blocked-ultra" }; } + }; + const blockedSettings = { + ...expandDialPreset("reasoning"), includeUltraReasoning: false + }; + + const changed = fakeDial("notice-settings-change"); + controller.registerDial(changed, blockedSettings); + await settle(); + controller.rotateDial(changed, 1); + await idle(controller, changed.id); + assert.equal((changed.feedbackCalls.at(-1) as { value: string }).value, "ULTRA OFF"); + controller.updateDialSettings(changed, { + ...blockedSettings, includeUltraReasoning: true + }); + await settle(); + assert.equal((changed.feedbackCalls.at(-1) as { value: string }).value, "HIGH"); + + const disposed = fakeDial("notice-disposed"); + controller.registerDial(disposed, blockedSettings); + await settle(); + controller.rotateDial(disposed, 1); + await idle(controller, disposed.id); + assert.equal((disposed.feedbackCalls.at(-1) as { value: string }).value, "ULTRA OFF"); + const disposedCalls = disposed.feedbackCalls.length; + controller.unregisterDial(disposed); + + const oldAction = fakeDial("notice-replaced"); + const newAction = fakeDial("notice-replaced"); + controller.registerDial(oldAction, blockedSettings); + await settle(); + controller.rotateDial(oldAction, 1); + await idle(controller, oldAction.id); + assert.equal((oldAction.feedbackCalls.at(-1) as { value: string }).value, "ULTRA OFF"); + const oldCalls = oldAction.feedbackCalls.length; + controller.registerDial(newAction, blockedSettings); + await settle(); + const newCalls = newAction.feedbackCalls.length; + + await new Promise((resolve) => setTimeout(resolve, 1_250)); + await settle(); + assert.equal(disposed.feedbackCalls.length, disposedCalls); + assert.equal(oldAction.feedbackCalls.length, oldCalls); + assert.equal(newAction.feedbackCalls.length, newCalls); + controller.unregisterDial(changed); + controller.unregisterDial(newAction); +}); + +test("a stale in-flight Ultra notice cannot overwrite replacement registration feedback", async () => { + for (const failsAfterWrite of [false, true]) { + const controller = new DeckController(); + const state = probe(controller); + const writes: string[] = []; + const noticeStarted = deferred(); + const releaseNotice = deferred(); + const makeAction = (owner: "old" | "new"): FakeDial => ({ + id: `notice-race-${failsAfterWrite}`, + feedbackCalls: [], + triggerCalls: [], + alerts: 0, + async setFeedback(payload: unknown) { + const value = String((payload as { value?: unknown }).value); + if (owner === "old" && value === "ULTRA OFF") { + noticeStarted.resolve(); + await releaseNotice.promise; + writes.push(`${owner}:${value}`); + if (failsAfterWrite) throw new Error("stale notice write failed"); + return; + } + writes.push(`${owner}:${value}`); + }, + async setTriggerDescription() {}, + async showAlert(this: { alerts: number }) { this.alerts += 1; } + } as unknown as FakeDial); + const oldAction = makeAction("old"); + const newAction = makeAction("new"); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return { outcome: "blocked-ultra" }; } + }; + const settings = { ...expandDialPreset("reasoning"), includeUltraReasoning: false }; + + controller.registerDial(oldAction, settings); + await settle(); + const oldRegistration = probe(controller).dials.get(oldAction.id)!; + controller.rotateDial(oldAction, 1); + await noticeStarted.promise; + controller.registerDial(newAction, settings); + await settle(); + releaseNotice.resolve(); + await oldRegistration.queue.idle(); + await settle(); + + assert.deepEqual(writes, [ + "old:HIGH", "new:HIGH", "old:ULTRA OFF", "new:HIGH" + ], failsAfterWrite ? "failed stale write" : "successful stale write"); + assert.equal(oldAction.alerts, 0); + assert.equal(newAction.alerts, 0); + controller.unregisterDial(newAction); + } +}); + +test("disposing during an in-flight failed Ultra notice does not restore or reject", async () => { + const controller = new DeckController(); + const state = probe(controller); + const writes: string[] = []; + const noticeStarted = deferred(); + const releaseNotice = deferred(); + const action = { + id: "notice-disposal-race", + feedbackCalls: [], + triggerCalls: [], + alerts: 0, + async setFeedback(payload: unknown) { + const value = String((payload as { value?: unknown }).value); + if (value === "ULTRA OFF") { + noticeStarted.resolve(); + await releaseNotice.promise; + } + writes.push(value); + if (value === "ULTRA OFF") throw new Error("disposed notice write failed"); + }, + async setTriggerDescription() {}, + async showAlert(this: { alerts: number }) { this.alerts += 1; } + } as unknown as FakeDial; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return { outcome: "blocked-ultra" }; } + }; + controller.registerDial(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: false + }); + await settle(); + const registration = probe(controller).dials.get(action.id)!; + controller.rotateDial(action, 1); + await noticeStarted.promise; + controller.unregisterDial(action); + releaseNotice.resolve(); + + await assert.doesNotReject(registration.queue.idle()); + await settle(); + assert.deepEqual(writes, ["HIGH", "ULTRA OFF"]); + assert.equal(action.alerts, 0); +}); + +test("failed Ultra notice feedback clears suppression and falls back to authoritative rendering", async () => { + const controller = new DeckController(); + const action = fakeDial("reasoning-blocked-feedback-failure", { rejectUltraFeedback: true }); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { ...structuredClone(SNAPSHOT), reasoningEffort: "high" } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return { outcome: "blocked-ultra" }; } + }; + controller.registerDial(action, { + ...expandDialPreset("reasoning"), includeUltraReasoning: false + }); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + + assert.match(JSON.stringify(action.feedbackCalls), /ULTRA OFF/); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "HIGH"); + assert.equal(action.alerts, 0); + controller.unregisterDial(action); +}); + +test("rotation rejects malformed or oversized events and atomically bounds one-shot backlog", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("bounded-rotation"); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const sends: string[] = []; + let first = true; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async runKeycap(keycapId) { + sends.push(keycapId); + if (first) { + first = false; + await gate; + } + } + }; + state.refresh = async () => {}; + controller.registerDial(action, { + ...expandDialPreset("custom"), + rotation: { + kind: "paired", counterClockwise: "keycap.FAST", clockwise: "keycap.FAST" + } + }); + + controller.rotateDial(action, 65); + controller.rotateDial(action, Number.NaN); + await settle(); + assert.deepEqual(sends, []); + assert.equal(action.alerts, 2, "each rejected physical event alerts once"); + + controller.rotateDial(action, 64); + controller.rotateDial(action, 64); + controller.rotateDial(action, 1); + await settle(); + assert.equal(sends.length, 1, "the accepted backlog starts in order"); + assert.equal(action.alerts, 3, "the overflowing event alerts once without partial admission"); + assert.equal(probe(controller).dials.get(action.id)!.queue.pendingCount, 128); + release(); + await idle(controller, action.id); + assert.equal(sends.length, 128); + assert.equal(probe(controller).dials.get(action.id)!.queue.pendingCount, 0); +}); + +test("rate-limit reset remains press-only and uses Encoder feedback for completed holds", async () => { + const controller = new DeckController(); + const action = fakeDial("protected-hold"); + const settings = { + ...expandDialPreset("custom"), + press: "usage.rate-limit-reset", + touchTap: "none" + }; + let begins = 0; + const finishes = [false, true]; + controller.beginRateLimitReset = () => { begins += 1; }; + controller.finishRateLimitReset = async () => finishes.shift() ?? false; + + controller.registerDial(action, settings); + assert.equal(probe(controller).dials.get(action.id)!.settings.touchTap, "none"); + await controller.touchDial(action); + assert.equal(begins, 0, "touch cannot start the protected hold"); + + await controller.beginDialPress(action); + await controller.finishDialPress(action); + assert.doesNotMatch(JSON.stringify(action.feedbackCalls), /RESET COMPLETE/); + await controller.beginDialPress(action); + await controller.finishDialPress(action); + assert.match(JSON.stringify(action.feedbackCalls), /RESET COMPLETE/); + await new Promise((resolve) => setTimeout(resolve, 450)); + assert.notEqual( + JSON.stringify(action.feedbackCalls.at(-1)), + JSON.stringify(action.feedbackCalls.find((call) => JSON.stringify(call).includes("RESET COMPLETE"))), + "normal feedback is restored after the temporary success indication" + ); +}); + +test("agent dispatch validates expected identity before down and releases the saved host assignment", async () => { + const controller = new DeckController(); + const state = probe(controller); + const sent: Array<[number, 0 | 1, string | undefined]> = []; + state.localHost = HOST; + state.routedSlots = [routedAgent(0, "thread-a")]; + state.microBridge = { + async sendAgent(slot, act, threadKey) { sent.push([slot, act, threadKey]); } + }; + + await assert.rejects(controller.sendAgent(0, 1, "wrong-thread"), /no longer matches/i); + assert.deepEqual(sent, []); + + await controller.sendAgent(0, 1, "thread-a"); + state.routedSlots = [routedAgent(0, "thread-b", { + hostId: "host-b", hostName: "Windows", platform: "win32" + })]; + await controller.sendAgent(0, 0, "thread-a"); + assert.deepEqual(sent, [ + [0, 1, "thread-a"], + [0, 0, "thread-a"] + ]); + + state.routedSlots = [routedAgent(0, "thread-c")]; + state.microBridge = { + async sendAgent() { throw new Error("bridge down failed"); } + }; + await assert.rejects(controller.sendAgent(0, 1, "thread-c"), /bridge down failed/); + assert.equal(state.pressedAgents.size, 0, "failed agent down leaves no pressed keypad route"); + await controller.sendAgent(0, 0, "thread-c"); + assert.equal(state.pressedAgents.size, 0, "failed agent gesture is consumed without a stale route"); +}); + +test("keypad agent up waits for a slow down and releases the captured assignment exactly once", async () => { + const controller = new DeckController(); + const state = probe(controller); + let releaseDown!: () => void; + const downGate = new Promise((resolve) => { releaseDown = resolve; }); + const events: Array<[number, 0 | 1, string | undefined]> = []; + state.localHost = HOST; + state.routedSlots = [routedAgent(0, "slow-thread")]; + state.microBridge = { + async sendAgent(slot, act, threadKey) { + events.push([slot, act, threadKey]); + if (act === 1) await downGate; + } + }; + + const down = controller.sendAgent(0, 1, "slow-thread"); + const up = controller.sendAgent(0, 0, "slow-thread"); + releaseDown(); + await Promise.all([down, up]); + + assert.deepEqual(events, [[0, 1, "slow-thread"], [0, 0, "slow-thread"]]); + assert.equal(state.pressedAgents.size, 0); +}); + +test("keypad agent up suppresses release when its deferred down fails", async () => { + const controller = new DeckController(); + const state = probe(controller); + let failDown!: (error: Error) => void; + const downGate = new Promise((_resolve, reject) => { failDown = reject; }); + const events: Array<[number, 0 | 1]> = []; + state.localHost = HOST; + state.routedSlots = [routedAgent(0, "failed-thread")]; + state.microBridge = { + async sendAgent(slot, act) { + events.push([slot, act]); + if (act === 1) await downGate; + } + }; + + const down = controller.sendAgent(0, 1, "failed-thread"); + const up = controller.sendAgent(0, 0, "failed-thread"); + failDown(new Error("relay down failed")); + await assert.rejects(down, /relay down failed/); + await up; + + assert.deepEqual(events, [[0, 1]]); + assert.equal(state.pressedAgents.size, 0); +}); + +test("action selector rotation does not execute until press", async () => { + const controller = new DeckController(); + const action = fakeDial("action-selector"); + const settings = { + ...expandDialPreset("actions"), + rotation: { + kind: "selector" as const, + source: "actions" as const, + wrap: true, + items: ["keycap.FAST", "keycap.APPR"] + } + }; + const keycaps: string[] = []; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async runKeycap(keycapId) { keycaps.push(keycapId); } + }; + + controller.registerDial(action, settings); + controller.rotateDial(action, 1); + await idle(controller, action.id); + assert.deepEqual(keycaps, []); + await controller.beginDialPress(action); + assert.deepEqual(keycaps, ["APPR"]); +}); + +test("empty agent and action selectors render no items and pressing still alerts", async () => { + for (const [id, settings] of [ + ["empty-agents", expandDialPreset("agents")], + ["empty-actions", { + ...expandDialPreset("actions"), + rotation: { kind: "selector" as const, source: "actions" as const, wrap: true, items: [] } + }] + ] as const) { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial(id); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.routedSlots = []; + controller.registerDial(action, settings); + await settle(); + + assert.equal( + (action.feedbackCalls.at(-1) as { value?: string }).value, + "NO ITEMS" + ); + await controller.beginDialPress(action); + await controller.finishDialPress(action); + assert.equal(action.alerts, 1); + } +}); + +test("dial host commands require ready health for starts but execute when ready", async () => { + const bindings = [ + "reasoning.increase", "keycap.FAST", "micro.ACT06", "joystick.up" + ] as const; + for (const health of ["degraded", "offline", "connecting"] as const) { + for (const binding of bindings) { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial(`${health}-${binding}`); + const sends: string[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: health, changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning(direction) { + sends.push(`reasoning:${direction}`); + return { outcome: "applied", reasoningEffort: "high" }; + }, + async runKeycap(keycapId) { sends.push(`keycap:${keycapId}`); }, + async sendAction(slot, act) { sends.push(`action:${slot}:${act}`); }, + async sendJoystick(direction, distance) { sends.push(`joystick:${direction}:${distance}`); } + }; + controller.registerDial(action, { ...expandDialPreset("custom"), press: binding }); + + await controller.beginDialPress(action); + await controller.finishDialPress(action); + + assert.deepEqual(sends, [], `${health} ${binding}`); + assert.equal(action.alerts, 1, `${health} ${binding}`); + } + } + + for (const binding of bindings) { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial(`ready-${binding}`); + const sends: string[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning(direction) { + sends.push(`reasoning:${direction}`); + return { outcome: "applied", reasoningEffort: "high" }; + }, + async runKeycap(keycapId) { sends.push(`keycap:${keycapId}`); }, + async sendAction(slot, act) { sends.push(`action:${slot}:${act}`); }, + async sendJoystick(direction, distance) { sends.push(`joystick:${direction}:${distance}`); } + }; + controller.registerDial(action, { ...expandDialPreset("custom"), press: binding }); + + await controller.beginDialPress(action); + await controller.finishDialPress(action); + + assert.equal(sends.length, binding.startsWith("micro.") || binding.startsWith("joystick.") ? 2 : 1); + assert.equal(action.alerts, 0, binding); + } +}); + +test("dial agent starts require their owner health and active releases survive degradation", async () => { + const controller = new DeckController(); + const state = probe(controller); + const blocked = fakeDial("agent-health-blocked"); + const released = fakeDial("release-after-degradation"); + const agentEvents: Array<[number, 0 | 1]> = []; + const actionEvents: Array<[string, 0 | 1]> = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.routedSlots = [routedAgent(0, "health-thread")]; + state.localHealth = { state: "degraded", changedAt: 1_000 }; + state.microBridge = { + async sendAgent(slot, act) { agentEvents.push([slot, act]); }, + async sendAction(slot, act) { actionEvents.push([slot, act]); } + }; + controller.registerDial(blocked, expandDialPreset("agents")); + await controller.beginDialPress(blocked); + await controller.finishDialPress(blocked); + assert.deepEqual(agentEvents, []); + assert.equal(blocked.alerts, 1); + + state.localHealth = { state: "ready", changedAt: 2_000 }; + controller.registerDial(released, { ...expandDialPreset("custom"), press: "micro.ACT06" }); + await controller.beginDialPress(released); + state.localHealth = { state: "offline", changedAt: 3_000 }; + await controller.finishDialPress(released); + assert.deepEqual(actionEvents, [["ACT06", 1], ["ACT06", 0]]); + assert.equal(released.alerts, 0); +}); + +test("remote dial starts require remote readiness and multi-host agent feedback shows a badge", async () => { + const controller = new DeckController(); + const state = probe(controller); + const blocked = fakeDial("remote-health-blocked"); + const badged = fakeDial("multi-host-badge"); + const commands: unknown[] = []; + let remoteHealth: HostHealth = { state: "degraded", changedAt: 1_000 }; + state.localHost = HOST; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.routedSlots = [routedAgent(0, "local-thread", HOST)]; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => remoteHealth, + currentSnapshot: () => undefined, + async send(command) { commands.push(command); } + }; + controller.registerDial(blocked, { ...expandDialPreset("custom"), press: "keycap.FAST" }); + await controller.beginDialPress(blocked); + await controller.finishDialPress(blocked); + assert.deepEqual(commands, []); + assert.equal(blocked.alerts, 1); + + remoteHealth = { state: "ready", changedAt: 2_000 }; + const ready = fakeDial("remote-health-ready"); + controller.registerDial(ready, { ...expandDialPreset("custom"), press: "keycap.FAST" }); + await controller.beginDialPress(ready); + assert.equal(commands.length, 1); + assert.equal(ready.alerts, 0); + + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + const configuredOnly = fakeDial("configured-relay-single-host"); + controller.registerDial(configuredOnly, expandDialPreset("agents")); + await settle(); + assert.equal((configuredOnly.feedbackCalls.at(-1) as { title?: string }).title, "AGENT 1"); + + state.routedSlots = [ + routedAgent(0, "local-thread", HOST), + routedAgent(1, "remote-thread", REMOTE_HOST) + ]; + controller.registerDial(badged, expandDialPreset("agents")); + await settle(); + assert.equal((badged.feedbackCalls.at(-1) as { title?: string }).title, "AGENT 1 • M"); + controller.rotateDial(badged, 1); + await idle(controller, badged.id); + assert.equal((badged.feedbackCalls.at(-1) as { title?: string }).title, "AGENT 2 • W"); +}); + +test("agent feedback health follows the highlighted owner instead of the function target", async () => { + const remoteHealth: HostHealth = { state: "offline", changedAt: 1_000 }; + const relay = { + currentHost: () => REMOTE_HOST, + currentHealth: () => remoteHealth, + currentSnapshot: () => undefined, + async send() {} + }; + + const localTarget = new DeckController(); + const localState = probe(localTarget); + const remoteAgent = fakeDial("remote-owner-offline"); + localState.localHost = HOST; + localState.localHealth = { state: "ready", changedAt: 1_000 }; + localState.targetHostId = HOST.hostId; + localState.targetPlatform = HOST.platform; + localState.routedSlots = [routedAgent(0, "remote-owner", REMOTE_HOST)]; + localState.relayClient = relay; + localTarget.registerDial(remoteAgent, expandDialPreset("agents")); + await settle(); + assert.equal((remoteAgent.feedbackCalls.at(-1) as { value?: string }).value, "OFFLINE"); + + const remoteTarget = new DeckController(); + const remoteState = probe(remoteTarget); + const localAgent = fakeDial("local-owner-ready"); + remoteState.localHost = HOST; + remoteState.localHealth = { state: "ready", changedAt: 1_000 }; + remoteState.targetHostId = REMOTE_HOST.hostId; + remoteState.targetPlatform = REMOTE_HOST.platform; + remoteState.routedSlots = [routedAgent(0, "local-owner", HOST)]; + remoteState.relayClient = relay; + remoteTarget.registerDial(localAgent, expandDialPreset("agents")); + await settle(); + assert.equal((localAgent.feedbackCalls.at(-1) as { value?: string }).value, "TASK LOCAL-OWNER"); +}); + +test("local dial usage refresh requires ready health", async () => { + for (const health of ["degraded", "offline", "connecting", "ready"] as const) { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial(`usage-refresh-${health}`); + let refreshes = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: health, changedAt: 1_000 }; + state.refreshLocalUsage = async () => { + refreshes += 1; + return SNAPSHOT; + }; + controller.registerDial(action, { + ...expandDialPreset("custom"), press: "usage.refresh" + }); + + await controller.beginDialPress(action); + await controller.finishDialPress(action); + + assert.equal(refreshes, health === "ready" ? 1 : 0, health); + assert.equal(action.alerts, health === "ready" ? 0 : 1, health); + } +}); + +test("dial rate-limit reset requires ready usage-host health", async () => { + for (const health of ["degraded", "ready"] as const) { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial(`reset-${health}`); + let resets = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: SNAPSHOT }; + state.localHealth = { state: health, changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async consumeRateLimitReset() { resets += 1; } + }; + state.refresh = async () => {}; + controller.registerDial(action, { + ...expandDialPreset("custom"), press: "usage.rate-limit-reset" + }); + const realNow = Date.now; + try { + Date.now = () => 50_000; + await controller.beginDialPress(action); + Date.now = () => 51_200; + await controller.finishDialPress(action); + } finally { + Date.now = realNow; + } + assert.equal(resets, health === "ready" ? 1 : 0); + assert.equal(action.alerts, health === "ready" ? 0 : 1); + controller.unregisterDial(action); + } +}); + +test("keypad and dial reset paths fail closed unless applicability is a positive safe integer", async () => { + const invalidApplicable: unknown[] = [undefined, null, 0, -1, 1.5, Number.NaN, Infinity, "1", false, [], {}]; + for (const route of ["keypad", "dial"] as const) { + for (const applicable of invalidApplicable) { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial(`${route}-${String(applicable)}`); + let resets = 0; + state.localHost = HOST; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { + ...structuredClone(SNAPSHOT), + usage: { ...structuredClone(SNAPSHOT.usage!), resetCreditsApplicable: applicable as number } + } + }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async consumeRateLimitReset() { resets += 1; } + }; + state.refresh = async () => {}; + controller.beginRateLimitReset(action, 10_000, route === "dial" ? HOST.hostId : undefined); + await assert.rejects( + controller.finishRateLimitReset(action, 11_200, route === "dial" ? HOST.hostId : undefined), + /No rate-limit reset credit is currently applicable\./ + ); + assert.equal(resets, 0, `${route}:${String(applicable)}`); + } + } + + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("valid-applicability"); + let resets = 0; + state.localHost = HOST; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: structuredClone(SNAPSHOT) }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async consumeRateLimitReset() { resets += 1; } + }; + state.refresh = async () => {}; + controller.beginRateLimitReset(action, 20_000); + assert.equal(await controller.finishRateLimitReset(action, 21_200), true); + assert.equal(resets, 1); +}); + +test("beginning a reset hold logs an initial render failure without an unhandled rejection or alert", async () => { + const controller = new DeckController(); + const action = fakeDial("reset-render-failure"); + const state = controller as unknown as { + rateLimitResetActions: Map; + renderRateLimitReset(action: unknown): Promise; + }; + state.rateLimitResetActions.set(action.id, action); + state.renderRateLimitReset = async () => { throw new Error("initial hold render failed"); }; + + const errors: string[] = []; + const unhandled: unknown[] = []; + const logger = streamDeck.logger as unknown as { error(message: string): void }; + const originalError = logger.error; + const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; + logger.error = (message) => { errors.push(message); }; + process.on("unhandledRejection", onUnhandled); + try { + controller.beginRateLimitReset(action, 10_000); + await settle(); + } finally { + process.off("unhandledRejection", onUnhandled); + logger.error = originalError; + } + + assert.deepEqual(unhandled, []); + assert.equal(errors.filter((message) => message.includes("initial hold render failed")).length, 1); + assert.equal(action.alerts, 0); +}); + +test("animation scheduling logs renderer failures and continues subsequent frames", async () => { + const controller = new DeckController(); + const state = controller as unknown as { + stopped: boolean; + scheduleAnimation(): void; + renderAnimatedAgents(): Promise; + renderResetHolds(): Promise; + }; + let agentFrames = 0; + let resetFrames = 0; + state.renderAnimatedAgents = async () => { + agentFrames += 1; + if (agentFrames === 1) throw new Error("agent frame failed"); + }; + state.renderResetHolds = async () => { + resetFrames += 1; + if (resetFrames === 2) throw new Error("reset frame failed"); + }; + + const scheduled: Array<() => unknown> = []; + const errors: string[] = []; + const originalSetTimeout = globalThis.setTimeout; + const logger = streamDeck.logger as unknown as { error(message: string): void }; + const originalError = logger.error; + globalThis.setTimeout = ((callback: (...args: unknown[]) => void) => { + scheduled.push(() => callback()); + return {} as NodeJS.Timeout; + }) as typeof setTimeout; + logger.error = (message) => { errors.push(message); }; + state.stopped = false; + try { + state.scheduleAnimation(); + await assert.doesNotReject(async () => (scheduled.shift()!)()); + await assert.doesNotReject(async () => (scheduled.shift()!)()); + state.stopped = true; + await assert.doesNotReject(async () => (scheduled.shift()!)()); + } finally { + state.stopped = true; + globalThis.setTimeout = originalSetTimeout; + logger.error = originalError; + } + + assert.equal(agentFrames, 3); + assert.equal(resetFrames, 3); + assert.equal(errors.filter((message) => message.includes("agent frame failed")).length, 1); + assert.equal(errors.filter((message) => message.includes("reset frame failed")).length, 1); + assert.equal(scheduled.length, 0); +}); + +test("two dials pressing the same momentary binding keep independent captured hosts", async () => { + const controller = new DeckController(); + const state = probe(controller); + const localEvents: Array<[string, 0 | 1]> = []; + const remoteEvents: Array<[string, 0 | 1]> = []; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async sendAction(slot, act) { localEvents.push([slot, act]); } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + async send(command) { + const value = command as { kind: string; slot: string; act: 0 | 1 }; + if (value.kind === "action") remoteEvents.push([value.slot, value.act]); + } + }; + const settings = { ...expandDialPreset("custom"), press: "micro.ACT06" }; + const localDial = fakeDial("same-local"); + const remoteDial = fakeDial("same-remote"); + controller.registerDial(localDial, settings); + controller.registerDial(remoteDial, settings); + + await controller.beginDialPress(localDial); + state.targetPlatform = REMOTE_HOST.platform; + state.targetHostId = REMOTE_HOST.hostId; + await controller.beginDialPress(remoteDial); + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + await controller.finishDialPress(localDial); + assert.deepEqual(localEvents, [["ACT06", 1], ["ACT06", 0]]); + assert.deepEqual(remoteEvents, [["ACT06", 1]]); + await controller.finishDialPress(remoteDial); + + assert.deepEqual(localEvents, [["ACT06", 1], ["ACT06", 0]]); + assert.deepEqual(remoteEvents, [["ACT06", 1], ["ACT06", 0]]); +}); + +test("a dial and keypad pressing the same binding do not overwrite each other's routes", async () => { + const controller = new DeckController(); + const state = probe(controller); + const localEvents: Array<[string, 0 | 1]> = []; + const remoteEvents: Array<[string, 0 | 1]> = []; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.microBridge = { + async sendAgent() {}, + async sendAction(slot, act) { localEvents.push([slot, act]); } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + async send(command) { + const value = command as { kind: string; slot: string; act: 0 | 1 }; + if (value.kind === "action") remoteEvents.push([value.slot, value.act]); + } + }; + const action = fakeDial("dial-keypad-overlap"); + controller.registerDial(action, { ...expandDialPreset("custom"), press: "micro.ACT06" }); + + await controller.sendMicroAction("ACT06", 1); + state.targetPlatform = REMOTE_HOST.platform; + state.targetHostId = REMOTE_HOST.hostId; + await controller.beginDialPress(action); + await controller.sendMicroAction("ACT06", 0); + assert.deepEqual(localEvents, [["ACT06", 1], ["ACT06", 0]]); + assert.deepEqual(remoteEvents, [["ACT06", 1]]); + await controller.finishDialPress(action); + assert.deepEqual(remoteEvents, [["ACT06", 1], ["ACT06", 0]]); +}); + +test("agent down rejects an owner change with the same thread key and leaves no pressed state", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("agent-owner-change"); + let releaseBacklog!: () => void; + const backlog = new Promise((resolve) => { releaseBacklog = resolve; }); + state.localHost = HOST; + state.routedSlots = [routedAgent(0, "same-thread", HOST)]; + const localEvents: unknown[] = []; + const remoteEvents: unknown[] = []; + state.microBridge = { + async sendAgent(...args) { localEvents.push(args); } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + async send(command) { remoteEvents.push(command); } + }; + controller.registerDial(action, expandDialPreset("agents")); + probe(controller).dials.get(action.id)!.queue.enqueue(() => backlog); + const down = controller.beginDialPress(action); + state.routedSlots = [routedAgent(0, "same-thread", REMOTE_HOST)]; + releaseBacklog(); + await down; + await controller.finishDialPress(action); + + assert.deepEqual(localEvents, []); + assert.deepEqual(remoteEvents, []); + assert.equal(state.pressedAgents.size, 0); + assert.equal(action.alerts, 1); +}); + +test("agent down rejects a source-slot identity change on the captured owner", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("agent-source-change"); + let releaseBacklog!: () => void; + const backlog = new Promise((resolve) => { releaseBacklog = resolve; }); + state.localHost = HOST; + state.routedSlots = [routedAgent(0, "same-thread", HOST)]; + const events: unknown[] = []; + state.microBridge = { + async sendAgent(...args) { events.push(args); } + }; + controller.registerDial(action, expandDialPreset("agents")); + probe(controller).dials.get(action.id)!.queue.enqueue(() => backlog); + const down = controller.beginDialPress(action); + const changed = routedAgent(0, "same-thread", HOST); + changed.sourceSlot = 4; + state.routedSlots = [changed]; + releaseBacklog(); + await down; + await controller.finishDialPress(action); + + assert.deepEqual(events, []); + assert.equal(action.alerts, 1); +}); + +test("failed momentary down suppresses unmatched up and a duplicate alert", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("failed-down"); + const events: Array<[string, 0 | 1]> = []; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async sendAction(slot, act) { + events.push([slot, act]); + if (act === 1) throw new Error("down failed"); + } + }; + controller.registerDial(action, { ...expandDialPreset("custom"), press: "micro.ACT06" }); + + await controller.beginDialPress(action); + await controller.finishDialPress(action); + + assert.deepEqual(events, [["ACT06", 1]]); + assert.equal(action.alerts, 1); +}); + +test("reset hold gate uses physical event timestamps instead of queue execution time", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("physical-hold"); + const settings = { ...expandDialPreset("custom"), press: "usage.rate-limit-reset" }; + let releaseBacklog!: () => void; + const backlog = new Promise((resolve) => { releaseBacklog = resolve; }); + state.localHost = HOST; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: SNAPSHOT }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + let resets = 0; + state.microBridge = { + async sendAgent() {}, + async consumeRateLimitReset() { resets += 1; } + }; + state.refresh = async () => {}; + controller.registerDial(action, settings); + probe(controller).dials.get(action.id)!.queue.enqueue(() => backlog); + + const realNow = Date.now; + try { + Date.now = () => 10_000; + const down = controller.beginDialPress(action); + Date.now = () => 10_100; + const up = controller.finishDialPress(action); + Date.now = () => 20_000; + releaseBacklog(); + await Promise.all([down, up]); + } finally { + Date.now = realNow; + } + assert.equal(resets, 0, "a 100ms physical hold cannot mature while queued"); + + const resetApi = controller as unknown as { + beginRateLimitReset(action: { id: string }, startedAt: number, sourceHostId?: string): void; + finishRateLimitReset(action: { id: string }, endedAt: number, sourceHostId?: string): Promise; + }; + resetApi.beginRateLimitReset(action, 30_000, HOST.hostId); + assert.equal(await resetApi.finishRateLimitReset(action, 31_200, HOST.hostId), true); + assert.equal(resets, 1, "a physical 1.2s hold remains eligible"); +}); + +test("unregister cancels pending work, releases active gestures, and isolates re-registration", async () => { + const controller = new DeckController(); + const state = probe(controller); + const events: Array<[string, 0 | 1]> = []; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async sendAction(slot, act) { events.push([slot, act]); } + }; + const settings = { ...expandDialPreset("custom"), press: "micro.ACT06" }; + const active = fakeDial("active-dispose"); + controller.registerDial(active, settings); + await controller.beginDialPress(active); + controller.unregisterDial(active); + await settle(); + assert.deepEqual(events, [["ACT06", 1], ["ACT06", 0]]); + + let releaseBacklog!: () => void; + const backlog = new Promise((resolve) => { releaseBacklog = resolve; }); + const oldAction = fakeDial("reused-id"); + const newAction = fakeDial("reused-id"); + controller.registerDial(oldAction, settings); + probe(controller).dials.get(oldAction.id)!.queue.enqueue(() => backlog); + const oldDown = controller.beginDialPress(oldAction); + controller.unregisterDial(oldAction); + controller.registerDial(newAction, settings); + releaseBacklog(); + await oldDown; + await settle(); + + assert.deepEqual(events, [["ACT06", 1], ["ACT06", 0]], "old queued work is canceled"); + assert.equal(probe(controller).dials.get(newAction.id)!.settings.press, "micro.ACT06"); + controller.unregisterDial({ id: newAction.id } as unknown as DialAction); + assert.equal( + probe(controller).dials.has(newAction.id), + false, + "WillDisappear may supply a fresh ActionContext object for the current id" + ); +}); + +test("successful reset stays successful when Encoder acknowledgement feedback fails", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("reset-feedback-failure", { rejectSuccessFeedback: true }); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: SNAPSHOT }; + state.localHealth = { state: "ready", changedAt: 1_000 }; + let resets = 0; + state.microBridge = { + async sendAgent() {}, + async consumeRateLimitReset() { resets += 1; } + }; + state.refresh = async () => {}; + controller.registerDial(action, { + ...expandDialPreset("custom"), + press: "usage.rate-limit-reset" + }); + await settle(); + + const realNow = Date.now; + try { + Date.now = () => 40_000; + await controller.beginDialPress(action); + Date.now = () => 41_200; + await controller.finishDialPress(action); + } finally { + Date.now = realNow; + } + await settle(); + + assert.equal(resets, 1); + assert.equal(action.alerts, 0, "display acknowledgement failure is not a command failure"); + assert.equal( + action.feedbackCalls.filter((call) => JSON.stringify(call).includes("RESET COMPLETE")).length, + 1 + ); + assert.doesNotMatch(JSON.stringify(action.feedbackCalls.at(-1)), /RESET COMPLETE/); +}); + +test("dial feedback error dedupe storage stays bounded", async () => { + const controller = new DeckController(); + for (let index = 0; index < 105; index += 1) { + controller.registerDial( + fakeDial(`description-error-${index}`, { descriptionError: `description-${index}` }), + expandDialPreset("reasoning") + ); + } + await settle(); + const state = probe(controller); + assert.equal(state.dialDescriptionErrors.size, 100); + assert.equal(state.dialRenderErrors.size <= 100, true); + assert.equal(state.dialSuccessErrors.size <= 100, true); +}); + +test("rotation detents use the host captured before their queue backlog", async () => { + const controller = new DeckController(); + const state = probe(controller); + const action = fakeDial("captured-detent"); + let releaseBacklog!: () => void; + const backlog = new Promise((resolve) => { releaseBacklog = resolve; }); + const localEvents: Array<[string, 0 | 1]> = []; + const remoteEvents: unknown[] = []; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.microBridge = { + async sendAgent() {}, + async sendAction(slot, act) { localEvents.push([slot, act]); } + }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => undefined, + async send(command) { remoteEvents.push(command); } + }; + const settings = { + ...expandDialPreset("custom"), + rotation: { kind: "paired" as const, counterClockwise: "micro.ACT06", clockwise: "micro.ACT06" } + }; + controller.registerDial(action, settings); + probe(controller).dials.get(action.id)!.queue.enqueue(() => backlog); + controller.rotateDial(action, 1); + state.targetPlatform = REMOTE_HOST.platform; + state.targetHostId = REMOTE_HOST.hostId; + releaseBacklog(); + await idle(controller, action.id); + + assert.deepEqual(localEvents, [["ACT06", 1], ["ACT06", 0]]); + assert.deepEqual(remoteEvents, []); +}); + +test("property inspector catalog requests require exact plain data and return target authority", async () => { + const controller = new DeckController(); + const state = probe(controller); + const sent: unknown[] = []; + const action = fakeDial("catalog-pi") as FakeDial & { sendToPropertyInspector(payload: unknown): Promise }; + action.sendToPropertyInspector = async (payload) => { sent.push(structuredClone(payload)); }; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshotGeneration = 9; + state.localSnapshot = { + host: HOST, + observedAt: 1_000, + snapshot: { + ...structuredClone(SNAPSHOT), + activeModelId: "gpt-5.6-sol", + activeModelDisplayName: "5.6 Sol", + reasoningEffort: "high", + modelCatalog: [{ + modelId: "gpt-5.6-sol", displayName: "5.6 Sol", + supportedReasoningEfforts: ["medium", "high", "ultra"] + }] + } + }; + + controller.registerDialPropertyInspector(action); + await settle(); + assert.equal(sent.length, 1, "appear pushes current authority"); + assert.deepEqual(sent[0], { + kind: "model-catalog", + requestGeneration: 0, + catalogRevision: 1, + available: true, + hostId: HOST.hostId, + platform: HOST.platform, + snapshotGeneration: 9, + activeModelId: "gpt-5.6-sol", + activeModelDisplayName: "5.6 Sol", + reasoningEffort: "high", + modelCatalog: [{ + modelId: "gpt-5.6-sol", displayName: "5.6 Sol", + supportedReasoningEfforts: ["medium", "high", "ultra"] + }] + }); + + controller.handleDialPropertyInspectorMessage(action, { kind: "request-model-catalog", requestGeneration: 7 }); + await settle(); + assert.equal((sent.at(-1) as { requestGeneration: number }).requestGeneration, 7); + assert.equal((sent.at(-1) as { catalogRevision: number }).catalogRevision, 2); + + for (const invalid of [ + { kind: "request-model-catalog", requestGeneration: 7, extra: true }, + { kind: "request-model-catalog", requestGeneration: -1 }, + Object.assign(Object.create({ kind: "request-model-catalog" }), { requestGeneration: 1 }), + Object.defineProperty({}, "kind", { get() { throw new Error("getter"); } }) + ]) controller.handleDialPropertyInspectorMessage(action, invalid); + await settle(); + assert.equal(sent.length, 2, "invalid requests are ignored without accessors"); +}); + +test("property inspector catalog pushes are deduped, monotonic, fenced, and failure-safe", async () => { + const controller = new DeckController(); + const state = probe(controller); + const firstSent: unknown[] = []; + const secondSent: unknown[] = []; + const first = fakeDial("catalog-life") as FakeDial & { sendToPropertyInspector(payload: unknown): Promise }; + first.sendToPropertyInspector = async (payload) => { firstSent.push(structuredClone(payload)); }; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshotGeneration = 1; + state.localSnapshot = { + host: HOST, observedAt: 1_000, + snapshot: { + ...structuredClone(SNAPSHOT), activeModelId: "gpt-5.6-sol", activeModelDisplayName: "5.6 Sol", + reasoningEffort: "high", modelCatalog: [{ + modelId: "gpt-5.6-sol", displayName: "5.6 Sol", supportedReasoningEfforts: ["high"] + }] + } + }; + controller.registerDialPropertyInspector(first); + await settle(); + await state.renderAll(); + assert.equal(firstSent.length, 1, "unchanged authority is deduped"); + + state.localSnapshotGeneration = 2; + state.localSnapshot.snapshot.reasoningEffort = "medium"; + state.localSnapshot.snapshot.modelCatalog![0]!.supportedReasoningEfforts = ["medium", "high"]; + await state.renderAll(); + assert.equal(firstSent.length, 2); + assert.equal((firstSent[1] as { catalogRevision: number }).catalogRevision, 2); + + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 2_000 }), + currentSnapshot: () => ({ + host: REMOTE_HOST, observedAt: 22, + snapshot: { + ...structuredClone(SNAPSHOT), activeModelId: "gpt-5.6-terra", activeModelDisplayName: "5.6 Terra", + reasoningEffort: "medium", modelCatalog: [{ + modelId: "gpt-5.6-terra", displayName: "5.6 Terra", supportedReasoningEfforts: ["medium"] + }] + } + }), + async send() {} + }; + state.targetPlatform = REMOTE_HOST.platform; + state.targetHostId = REMOTE_HOST.hostId; + await state.renderAll(); + assert.equal((firstSent[2] as { hostId: string }).hostId, REMOTE_HOST.hostId, "host switch pushes without reopen"); + assert.equal((firstSent[2] as { catalogRevision: number }).catalogRevision, 3); + + controller.unregisterDialPropertyInspector(first); + const second = fakeDial("catalog-life") as FakeDial & { sendToPropertyInspector(payload: unknown): Promise }; + second.sendToPropertyInspector = async (payload) => { secondSent.push(structuredClone(payload)); }; + controller.registerDialPropertyInspector(second); + await settle(); + assert.equal((secondSent[0] as { catalogRevision: number }).catalogRevision, 4); + + controller.handleDialPropertyInspectorMessage(first, { kind: "request-model-catalog", requestGeneration: 9 }); + await settle(); + assert.equal(firstSent.length, 3, "stale action cannot receive replies"); + assert.equal(secondSent.length, 1); + + second.sendToPropertyInspector = async () => { throw new Error("closed inspector"); }; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "degraded", reason: "local-bridge-unavailable", changedAt: 2_000 }; + await assert.doesNotReject(state.renderAll()); + controller.unregisterDialPropertyInspector(second); +}); + +test("late catalog send from a disappeared inspector carries an older fenced revision", async () => { + const controller = new DeckController(); + const state = probe(controller); + const late = deferred(); + const delivered: Array<{ owner: string; payload: unknown }> = []; + const oldAction = fakeDial("catalog-late") as FakeDial & { sendToPropertyInspector(payload: unknown): Promise }; + oldAction.sendToPropertyInspector = async (payload) => { + await late.promise; + delivered.push({ owner: "old", payload: structuredClone(payload) }); + }; + const replacement = fakeDial("catalog-late") as FakeDial & { sendToPropertyInspector(payload: unknown): Promise }; + replacement.sendToPropertyInspector = async (payload) => { + delivered.push({ owner: "new", payload: structuredClone(payload) }); + }; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "degraded", reason: "local-bridge-unavailable", changedAt: 1_000 }; + + controller.registerDialPropertyInspector(oldAction); + controller.unregisterDialPropertyInspector(oldAction); + controller.registerDialPropertyInspector(replacement); + await settle(); + late.resolve(); + await settle(); + + assert.deepEqual(delivered.map(({ owner }) => owner), ["new", "old"]); + assert.deepEqual(delivered.map(({ payload }) => (payload as { catalogRevision: number }).catalogRevision), [2, 1]); + controller.unregisterDialPropertyInspector(replacement); +}); + +test("stale same-id inspector disappear cannot unregister its replacement", async () => { + const controller = new DeckController(); + const state = probe(controller); + const oldSent: unknown[] = []; + const replacementSent: unknown[] = []; + const oldAction = fakeDial("catalog-replaced") as FakeDial & { sendToPropertyInspector(payload: unknown): Promise }; + oldAction.sendToPropertyInspector = async (payload) => { oldSent.push(payload); }; + const replacement = fakeDial("catalog-replaced") as FakeDial & { sendToPropertyInspector(payload: unknown): Promise }; + replacement.sendToPropertyInspector = async (payload) => { replacementSent.push(payload); }; + state.localHost = HOST; + state.targetPlatform = HOST.platform; + state.targetHostId = HOST.hostId; + state.localHealth = { state: "degraded", reason: "local-bridge-unavailable", changedAt: 1_000 }; + + controller.registerDialPropertyInspector(oldAction); + controller.registerDialPropertyInspector(replacement); + await settle(); + controller.unregisterDialPropertyInspector(oldAction); + controller.handleDialPropertyInspectorMessage(replacement, { + kind: "request-model-catalog", requestGeneration: 8 + }); + await settle(); + + assert.equal(oldSent.length, 1); + assert.equal(replacementSent.length, 2, "replacement remains registered after stale disappear"); + assert.equal((replacementSent.at(-1) as { requestGeneration: number }).requestGeneration, 8); + controller.unregisterDialPropertyInspector(replacement); +}); + +test("rapid model preset detents resolve one direction at a time from each confirmed pair", async () => { + const controller = new DeckController(); + const action = fakeDial("model-preset-rapid"); + const state = probe(controller); + const requests: ModelPresetRequest[] = []; + const releases = [deferred(), deferred(), deferred()]; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + const index = requests.length; + requests.push(structuredClone(request)); + await releases[index]!.promise; + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(action, modelPresetSettings()); + await settle(); + + controller.rotateDial(action, 3); + await settle(); + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"] + ]); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "SWITCHING…"); + + releases[0]!.resolve(); + await settle(); + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"], ["gpt-5.6-terra", "medium"] + ]); + releases[1]!.resolve(); + await settle(); + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"], ["gpt-5.6-terra", "medium"], ["gpt-5.6-sol", "high"] + ]); + releases[2]!.resolve(); + await idle(controller, action.id); + + assert.equal(state.localSnapshotGeneration, 3); + assert.equal(state.localSnapshot.snapshot.activeModelId, "gpt-5.6-sol"); + assert.equal(state.localSnapshot.snapshot.reasoningEffort, "high"); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "5.6 SOL"); + controller.unregisterDial(action); +}); + +test("model preset directions use authoritative edges and skip invalid saved pairs", async () => { + const controller = new DeckController(); + const action = fakeDial("model-preset-edges"); + const state = probe(controller); + const requests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot("gpt-5.6-sol", "low") }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(action, modelPresetSettings([ + { modelId: "removed-model", reasoningEffort: "high" }, + { modelId: "gpt-5.6-sol", reasoningEffort: "medium" }, + { modelId: "gpt-5.6-terra", reasoningEffort: "medium" } + ])); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + state.localSnapshot = { ...state.localSnapshot, snapshot: modelSnapshot("gpt-5.6-sol", "low") }; + controller.rotateDial(action, -1); + await idle(controller, action.id); + + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"], ["gpt-5.6-terra", "medium"] + ]); + controller.unregisterDial(action); +}); + +test("model preset queue rejects overflow and rechecks registration and target before execution", async () => { + const controller = new DeckController(); + const action = fakeDial("model-preset-lifecycle"); + const replacement = fakeDial(action.id); + const state = probe(controller); + const first = deferred(); + const requests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + if (requests.length === 1) return first.promise; + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(action, modelPresetSettings()); + await settle(); + + controller.rotateDial(action, 64); + controller.rotateDial(action, 64); + controller.rotateDial(action, 1); + await settle(); + assert.equal(requests.length, 1); + assert.equal(action.alerts, 1, "the 129th pending detent is rejected as one event"); + + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + const oldRegistration = state.dials.get(action.id)!; + controller.registerDial(replacement, modelPresetSettings()); + const oldFeedbackCount = action.feedbackCalls.length; + first.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await settle(); + await oldRegistration.queue.idle(); + assert.equal(requests.length, 1, "queued old-registration directions never execute locally"); + assert.equal(action.feedbackCalls.length, oldFeedbackCount); + controller.unregisterDial(replacement); +}); + +test("queued model preset directions resolve against a target host changed before execution", async () => { + const controller = new DeckController(); + const action = fakeDial("model-preset-target-change"); + const state = probe(controller); + const first = deferred(); + const requests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + return first.promise; + } + }; + controller.registerDial(action, modelPresetSettings()); + await settle(); + + controller.rotateDial(action, 2); + await settle(); + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 2_000 }), + currentSnapshot: () => ({ host: REMOTE_HOST, observedAt: 2_000, snapshot: modelSnapshot() }), + async send() { throw new Error("Task 6 relay path must not be used yet"); } + }; + first.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await idle(controller, action.id); + + assert.equal(requests.length, 1, "the queued second direction does not reuse the captured local host"); + assert.equal(action.alerts, 1, "the current remote target is refused until relay support lands"); + controller.unregisterDial(action); +}); + +test("disposing a model preset dial prevents held completion and queued detents from writing feedback", async () => { + const controller = new DeckController(); + const action = fakeDial("model-preset-disposed"); + const state = probe(controller); + const first = deferred(); + let requests = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset() { requests += 1; return first.promise; } + }; + controller.registerDial(action, modelPresetSettings()); + await settle(); + const registration = state.dials.get(action.id)!; + controller.rotateDial(action, 2); + await settle(); + controller.unregisterDial(action); + const feedbackCount = action.feedbackCalls.length; + first.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await registration.queue.idle(); + + assert.equal(requests, 1); + assert.equal(action.feedbackCalls.length, feedbackCount); +}); + +test("local model preset success fences a held poll, patches the exact pair immutably, and redraws", async () => { + const controller = new DeckController(); + const action = fakeDial("model-preset-local-patch"); + const state = probe(controller); + const stalePoll = deferred(); + const applied = deferred(); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + const originalHostSnapshot = state.localSnapshot; + const originalSnapshot = state.localSnapshot.snapshot; + state.microBridge = { + async sendAgent() {}, + async refresh() { return stalePoll.promise; }, + async applyModelPreset() { return applied.promise; } + }; + controller.registerDial(action, modelPresetSettings()); + await settle(); + const poll = state.refresh(); + await settle(); + + controller.rotateDial(action, 1); + await settle(); + assert.equal((action.feedbackCalls.at(-1) as { value: string }).value, "SWITCHING…"); + applied.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await idle(controller, action.id); + + assert.notEqual(state.localSnapshot, originalHostSnapshot); + assert.notEqual(state.localSnapshot.snapshot, originalSnapshot); + assert.equal(state.localSnapshot.snapshot.activeModelId, "gpt-5.6-sol"); + assert.equal(state.localSnapshot.snapshot.activeModelDisplayName, "5.6 Sol"); + assert.equal(state.localSnapshot.snapshot.reasoningEffort, "medium"); + assert.equal((action.feedbackCalls.at(-1) as { detail: string }).detail, "MEDIUM"); + + stalePoll.resolve(modelSnapshot("gpt-5.6-terra", "medium")); + await poll; + assert.equal(state.localSnapshot.snapshot.activeModelId, "gpt-5.6-sol", "older poll is fenced"); + assert.equal(state.localSnapshot.snapshot.reasoningEffort, "medium"); + controller.unregisterDial(action); +}); + +test("refused or malformed model preset results reconcile actual state without rollback", async () => { + let getterReads = 0; + const accessorResult = Object.defineProperty( + { modelId: "gpt-5.6-terra" }, "reasoningEffort", + { enumerable: true, get() { getterReads += 1; return "medium"; } } + ); + for (const [index, result] of [ + new Error("selection refused"), + { modelId: "gpt-5.6-terra" }, + { modelId: "gpt-5.6-terra", reasoningEffort: "medium", extra: true }, + { modelId: "gpt-5.6-terra", reasoningEffort: "medium", [Symbol("extra")]: true }, + accessorResult + ].entries()) { + const controller = new DeckController(); + const action = fakeDial(`model-preset-failure-${index}`); + const state = probe(controller); + const requests: ModelPresetRequest[] = []; + let refreshes = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + if (result instanceof Error) throw result; + return result as ModelPresetExecution; + } + }; + state.refresh = async () => { + refreshes += 1; + state.localSnapshot = { + host: HOST, observedAt: 2_000, snapshot: modelSnapshot("gpt-5.6-terra", "high") + }; + state.localHealth = { state: "ready", changedAt: 2_000 }; + }; + controller.registerDial(action, modelPresetSettings()); + await settle(); + + controller.rotateDial(action, 1); + await idle(controller, action.id); + + assert.equal(requests.length, 1, "no rollback callback is attempted"); + assert.equal(refreshes, 1, "failure forces one authoritative reconciliation"); + assert.equal((action.feedbackCalls.at(-1) as { detail: string }).detail, "HIGH · UNLISTED"); + assert.equal(action.alerts, 1); + controller.unregisterDial(action); + } + assert.equal(getterReads, 0, "result validation never invokes accessors"); +}); + +test("different model preset knobs resolve in controller-wide arrival order", async () => { + const controller = new DeckController(); + const firstDial = fakeDial("model-preset-global-first"); + const secondDial = fakeDial("model-preset-global-second"); + const state = probe(controller); + const firstResult = deferred(); + const requests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + if (requests.length === 1) return firstResult.promise; + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(firstDial, modelPresetSettings()); + controller.registerDial(secondDial, modelPresetSettings()); + await settle(); + + controller.rotateDial(firstDial, 1); + controller.rotateDial(secondDial, 1); + await settle(); + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"] + ], "the second knob waits to resolve until the first confirms"); + + firstResult.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await Promise.all([ + state.dials.get(firstDial.id)!.queue.idle(), + state.dials.get(secondDial.id)!.queue.idle() + ]); + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"], ["gpt-5.6-terra", "medium"] + ]); + controller.unregisterDial(firstDial); + controller.unregisterDial(secondDial); +}); + +test("a model preset waits for predecessor reasoning confirmation before resolving its pair", async () => { + const controller = new DeckController(); + const reasoningDial = fakeDial("reasoning-global-first"); + const presetDial = fakeDial("model-preset-global-after-reasoning"); + const state = probe(controller); + const reasoningResult = deferred(); + const presetRequests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return reasoningResult.promise; }, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(reasoningDial, expandDialPreset("reasoning")); + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + + controller.rotateDial(reasoningDial, -1); + controller.rotateDial(presetDial, 1); + await settle(); + assert.deepEqual(presetRequests, [], "preset resolution waits behind the reasoning mutation"); + + reasoningResult.resolve({ outcome: "applied", reasoningEffort: "medium" }); + await Promise.all([ + state.dials.get(reasoningDial.id)!.queue.idle(), + state.dials.get(presetDial.id)!.queue.idle() + ]); + assert.deepEqual(presetRequests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-terra", "medium"] + ]); + controller.unregisterDial(reasoningDial); + controller.unregisterDial(presetDial); +}); + +test("reasoning waiting on the shared mutation order rechecks registration disposal", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("model-preset-global-blocker"); + const reasoningDial = fakeDial("reasoning-global-disposed"); + const state = probe(controller); + const presetResult = deferred(); + let reasoningCalls = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset() { return presetResult.promise; }, + async adjustReasoning() { + reasoningCalls += 1; + return { outcome: "applied", reasoningEffort: "high" }; + } + }; + controller.registerDial(presetDial, modelPresetSettings()); + controller.registerDial(reasoningDial, expandDialPreset("reasoning")); + await settle(); + const reasoningRegistration = state.dials.get(reasoningDial.id)!; + + controller.rotateDial(presetDial, 1); + controller.rotateDial(reasoningDial, 1); + await settle(); + controller.unregisterDial(reasoningDial); + presetResult.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await Promise.all([ + state.dials.get(presetDial.id)!.queue.idle(), + reasoningRegistration.queue.idle() + ]); + + assert.equal(reasoningCalls, 0); + controller.unregisterDial(presetDial); +}); + +test("public reasoning patches its confirmed effort before a waiting preset resolves", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("model-preset-after-public-reasoning"); + const state = probe(controller); + const reasoningResult = deferred(); + const presetRequests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return reasoningResult.promise; }, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + + const reasoning = controller.adjustReasoning("decrease"); + controller.rotateDial(presetDial, 1); + await settle(); + assert.deepEqual(presetRequests, []); + reasoningResult.resolve({ outcome: "applied", reasoningEffort: "medium" }); + await reasoning; + await state.dials.get(presetDial.id)!.queue.idle(); + + assert.equal(state.localSnapshotGeneration, 2, "reasoning and preset each fence older snapshots"); + assert.deepEqual(presetRequests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-terra", "medium"] + ]); + controller.unregisterDial(presetDial); +}); + +test("incoming relay reasoning patches before release and fences a held refresh from a waiting preset", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("model-preset-after-relay-reasoning"); + const state = probe(controller); + const staleRefresh = deferred(); + const reasoningResult = deferred(); + const presetRequests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async refresh() { return staleRefresh.promise; }, + async adjustReasoning() { return reasoningResult.promise; }, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + const olderRefresh = state.refresh(); + await settle(); + + const relayReasoning = state.adjustLocalReasoningFromRelay("decrease", { includeUltra: false }); + controller.rotateDial(presetDial, 1); + await settle(); + reasoningResult.resolve({ outcome: "applied", reasoningEffort: "medium" }); + await relayReasoning; + await state.dials.get(presetDial.id)!.queue.idle(); + + assert.deepEqual(presetRequests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-terra", "medium"] + ]); + staleRefresh.resolve(modelSnapshot("gpt-5.6-sol", "high")); + await olderRefresh; + assert.equal(state.localSnapshot.snapshot.activeModelId, "gpt-5.6-terra"); + assert.equal(state.localSnapshot.snapshot.reasoningEffort, "medium"); + controller.unregisterDial(presetDial); +}); + +test("shared local reasoning validation is descriptor-safe and failures retain last-known state", async () => { + const controller = new DeckController(); + const state = probe(controller); + let getterReads = 0; + const accessorExecution = Object.defineProperty( + { outcome: "applied" }, "reasoningEffort", + { enumerable: true, get() { getterReads += 1; return "medium"; } } + ); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + const retained = state.localSnapshot; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return accessorExecution as ReasoningAdjustmentExecution; } + }; + + await assert.rejects(controller.adjustReasoning("decrease"), /invalid reasoning adjustment result/i); + assert.equal(getterReads, 0); + assert.equal(state.localSnapshot, retained); + assert.equal(state.localSnapshotGeneration, 1); + assert.equal(state.localHealth.state, "ready"); + + state.microBridge.adjustReasoning = async () => { throw new Error("bridge unavailable"); }; + await assert.rejects(controller.adjustReasoning("decrease"), /bridge unavailable/); + assert.equal(state.localSnapshot, retained); + assert.equal(state.localSnapshotGeneration, 2); + assert.equal(state.localHealth.state, "ready"); +}); + +test("applied reasoning without effort reconciles authoritatively before a waiting preset resolves", async () => { + for (const [name, execution] of [ + ["missing", { outcome: "applied" }], + ["malformed", { outcome: "applied", reasoningEffort: " medium " }] + ] as const) { + const controller = new DeckController(); + const presetDial = fakeDial(`model-preset-after-${name}-reasoning-effort`); + const state = probe(controller); + const reconciliation = deferred(); + const presetRequests: ModelPresetRequest[] = []; + let refreshes = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return execution as ReasoningAdjustmentExecution; }, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + state.refresh = async () => { + refreshes += 1; + await reconciliation.promise; + state.localSnapshot = { + host: HOST, observedAt: 2_000, snapshot: modelSnapshot("gpt-5.6-sol", "medium") + }; + state.localHealth = { state: "ready", changedAt: 2_000 }; + }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + + const reasoning = controller.adjustReasoning("decrease"); + controller.rotateDial(presetDial, 1); + await settle(); + assert.equal(refreshes, 1, name); + assert.deepEqual(presetRequests, [], `${name} effort cannot release a stale preset target`); + + reconciliation.resolve(); + await reasoning; + await state.dials.get(presetDial.id)!.queue.idle(); + assert.deepEqual(presetRequests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-terra", "medium"] + ], name); + controller.unregisterDial(presetDial); + } +}); + +test("unconfirmed applied reasoning degrades and prevents a dependent preset when reconciliation fails", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("model-preset-after-unreconciled-reasoning"); + const state = probe(controller); + let presetCalls = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return { outcome: "applied" }; }, + async applyModelPreset(request) { + presetCalls += 1; + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + state.refresh = async () => { throw new Error("authoritative refresh unavailable"); }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + + const reasoning = controller.adjustReasoning("decrease"); + controller.rotateDial(presetDial, 1); + await assert.rejects(reasoning, /authoritative reasoning state is unavailable/i); + await state.dials.get(presetDial.id)!.queue.idle(); + + assert.equal(state.localHealth.state, "degraded"); + assert.equal(state.localSnapshot.snapshot.reasoningEffort, "high", "last-known data is retained"); + assert.equal(presetCalls, 0, "dependent model mutation is refused on degraded authority"); + controller.unregisterDial(presetDial); +}); + +test("shutdown invalidates an in-flight preset and cancels queued dial and public mutations", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("model-preset-shutdown"); + const state = probe(controller); + const firstResult = deferred(); + const presetRequests: ModelPresetRequest[] = []; + let reasoningCalls = 0; + let closeCalls = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + const retainedSnapshot = state.localSnapshot; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + return firstResult.promise; + }, + async adjustReasoning() { + reasoningCalls += 1; + return { outcome: "applied", reasoningEffort: "medium" }; + }, + close() { closeCalls += 1; } + }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + const registration = state.dials.get(presetDial.id)!; + + controller.rotateDial(presetDial, 2); + await settle(); + assert.equal(presetRequests.length, 1); + const queuedPublic = controller.adjustReasoning("decrease"); + await settle(); + controller.stop(); + const feedbackCount = presetDial.feedbackCalls.length; + const queuedPublicOutcome = queuedPublic.then( + () => "resolved" as const, + () => "rejected" as const + ); + assert.equal(await Promise.race([ + queuedPublicOutcome, + settle().then(() => "pending" as const) + ]), "rejected", "shutdown rejects queued callers without waiting for the held bridge call"); + firstResult.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await registration.queue.idle(); + await assert.rejects(queuedPublic, /stopped|superseded|lifecycle/i); + await settle(); + + assert.equal(closeCalls, 1); + assert.equal(presetRequests.length, 1, "queued preset never reconnects after bridge close"); + assert.equal(reasoningCalls, 0, "queued public reasoning never reaches the closed bridge"); + assert.equal(state.localSnapshot, retainedSnapshot, "late in-flight confirmation cannot patch state"); + assert.equal(presetDial.feedbackCalls.length, feedbackCount); + assert.equal(state.dials.size, 0, "shutdown disposes all dial registrations"); +}); + +test("shutdown catches and logs both asynchronous relay close failures", async () => { + const controller = new DeckController(); + const state = controller as unknown as { + mobileRelayServer: { close(): Promise }; + localMobileRelayServer: { close(): Promise }; + microBridge: { close(): void }; + }; + state.mobileRelayServer = { async close() { throw new Error("mobile close failed"); } }; + state.localMobileRelayServer = { async close() { throw new Error("local close failed"); } }; + state.microBridge = { close() {} }; + const errors: string[] = []; + const unhandled: unknown[] = []; + const logger = streamDeck.logger as unknown as { error(message: string): void }; + const originalError = logger.error; + const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; + logger.error = (message) => { errors.push(message); }; + process.on("unhandledRejection", onUnhandled); + try { + controller.stop(); + await settle(); + await settle(); + } finally { + process.off("unhandledRejection", onUnhandled); + logger.error = originalError; + } + assert.deepEqual(unhandled, []); + assert.equal(errors.some((message) => message.includes("mobile close failed")), true); + assert.equal(errors.some((message) => message.includes("local close failed")), true); +}); + +test("controller-wide model and reasoning backlog rejects mixed sources and recovers after drain", async () => { + const controller = new DeckController(); + const firstDial = fakeDial("global-cap-first"); + const secondDial = fakeDial("global-cap-second"); + const overflowDial = fakeDial("global-cap-overflow"); + const state = probe(controller); + const firstResult = deferred(); + const presetRequests: ModelPresetRequest[] = []; + let reasoningCalls = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + if (presetRequests.length === 1) return firstResult.promise; + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + }, + async adjustReasoning() { + reasoningCalls += 1; + return { outcome: "applied", reasoningEffort: "medium" }; + } + }; + controller.registerDial(firstDial, modelPresetSettings()); + controller.registerDial(secondDial, modelPresetSettings()); + controller.registerDial(overflowDial, modelPresetSettings()); + await settle(); + + controller.rotateDial(firstDial, 64); + controller.rotateDial(secondDial, 64); + controller.rotateDial(overflowDial, 1); + const publicOverflow = controller.adjustReasoning("increase").then( + () => false, + (error: unknown) => /model|reasoning|mutation|queue.*full/i.test(String(error)) + ); + const relayOverflow = state.adjustLocalReasoningFromRelay( + "decrease", { includeUltra: false } + ).then( + () => false, + (error: unknown) => /model|reasoning|mutation|queue.*full/i.test(String(error)) + ); + await settle(); + + assert.equal(state.modelReasoningMutationPending, 128); + assert.equal(presetRequests.length, 1); + assert.equal(overflowDial.alerts, 1, "overflowing dial detent is rejected immediately"); + firstResult.resolve({ + modelId: presetRequests[0]!.modelId, + reasoningEffort: presetRequests[0]!.reasoningEffort + }); + const [, , , publicRejected, relayRejected] = await Promise.all([ + state.dials.get(firstDial.id)!.queue.idle(), + state.dials.get(secondDial.id)!.queue.idle(), + state.dials.get(overflowDial.id)!.queue.idle(), + publicOverflow, + relayOverflow + ]); + + assert.equal(publicRejected, true); + assert.equal(relayRejected, true); + assert.equal(presetRequests.length, 128); + assert.equal(reasoningCalls, 0, "overflowed public and relay mutations never reach the bridge"); + assert.equal(state.modelReasoningMutationPending, 0); + await state.adjustLocalReasoningFromRelay("increase", { includeUltra: true }); + assert.equal(reasoningCalls, 1, "capacity is released after the shared tail drains"); + assert.equal(state.modelReasoningMutationPending, 0); +}); + +test("delayed reasoning confirmation cannot overwrite a newer authoritative generation", async () => { + const controller = new DeckController(); + const state = probe(controller); + const heldReasoning = deferred(); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return heldReasoning.promise; } + }; + + const adjustment = state.adjustLocalReasoningFromRelay("increase", { includeUltra: true }); + await settle(); + const newerSnapshot = { + host: HOST, + observedAt: 2_000, + snapshot: modelSnapshot("gpt-5.6-terra", "medium") + }; + state.localSnapshotGeneration += 1; + state.committedLocalSnapshotGeneration = state.localSnapshotGeneration; + state.localSnapshot = newerSnapshot; + state.refresh = async () => { + const generation = ++state.localSnapshotGeneration; + state.localSnapshot = newerSnapshot; + state.committedLocalSnapshotGeneration = generation; + }; + heldReasoning.resolve({ outcome: "applied", reasoningEffort: "high" }); + + assert.deepEqual(await adjustment, { outcome: "applied", reasoningEffort: "medium" }); + assert.equal(state.localSnapshot, newerSnapshot); +}); + +test("delayed model preset confirmation cannot overwrite a newer authoritative generation", async () => { + const controller = new DeckController(); + const dial = fakeDial("model-preset-newer-generation"); + const state = probe(controller); + const heldPreset = deferred(); + const requests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + return heldPreset.promise; + } + }; + controller.registerDial(dial, modelPresetSettings()); + await settle(); + + controller.rotateDial(dial, 1); + await settle(); + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"] + ]); + const newerSnapshot = { + host: HOST, + observedAt: 2_000, + snapshot: modelSnapshot("gpt-5.6-terra", "medium") + }; + state.localSnapshotGeneration += 1; + state.committedLocalSnapshotGeneration = state.localSnapshotGeneration; + state.localSnapshot = newerSnapshot; + state.refresh = async () => { + const generation = ++state.localSnapshotGeneration; + state.localSnapshot = newerSnapshot; + state.committedLocalSnapshotGeneration = generation; + }; + heldPreset.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await idle(controller, dial.id); + + assert.equal(state.localSnapshot, newerSnapshot); + assert.equal((dial.feedbackCalls.at(-1) as { value: string }).value, "5.6 TERRA"); + controller.unregisterDial(dial); +}); + +test("reasoning requires a post-result refresh when a newer refresh committed the old pair", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("reasoning-awaits-refresh-commit"); + const state = probe(controller); + const heldReasoning = deferred(); + const heldRefresh = deferred(); + const postResultRefresh = deferred(); + const presetRequests: ModelPresetRequest[] = []; + let postResultRefreshCalls = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.localSnapshotGeneration = 0; + state.committedLocalSnapshotGeneration = 0; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return heldReasoning.promise; }, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + state.refresh = async () => { + postResultRefreshCalls += 1; + const generation = ++state.localSnapshotGeneration; + const snapshot = await postResultRefresh.promise; + state.localSnapshot = { host: HOST, observedAt: 3_000, snapshot }; + state.committedLocalSnapshotGeneration = generation; + state.localHealth = { state: "ready", changedAt: 3_000 }; + }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + + let reasoningSettled = false; + const adjustment = state.adjustLocalReasoningFromRelay("increase", { includeUltra: true }) + .finally(() => { reasoningSettled = true; }); + await settle(); + const refreshGeneration = ++state.localSnapshotGeneration; + const refresh = heldRefresh.promise.then((snapshot) => { + state.localSnapshot = { host: HOST, observedAt: 2_000, snapshot }; + state.committedLocalSnapshotGeneration = refreshGeneration; + state.localHealth = { state: "ready", changedAt: 2_000 }; + }); + state.refreshInFlight = refresh; + heldRefresh.resolve(modelSnapshot()); + await refresh; + state.refreshInFlight = undefined; + controller.rotateDial(presetDial, 1); + heldReasoning.resolve({ outcome: "applied", reasoningEffort: "xhigh" }); + await settle(); + + assert.equal(reasoningSettled, false); + assert.equal(postResultRefreshCalls, 1); + assert.equal(presetRequests.length, 0, "dependent preset remains behind pre-confirmation authority"); + postResultRefresh.resolve(modelSnapshot("gpt-5.6-terra", "medium")); + assert.deepEqual(await adjustment, { outcome: "applied", reasoningEffort: "medium" }); + await idle(controller, presetDial.id); + assert.deepEqual(presetRequests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "high"] + ]); + controller.unregisterDial(presetDial); +}); + +test("model preset requires a post-result refresh when a newer refresh committed the old pair", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("preset-awaits-refresh-commit"); + const state = probe(controller); + const heldPreset = deferred(); + const heldRefresh = deferred(); + const postResultRefresh = deferred(); + const presetRequests: ModelPresetRequest[] = []; + let postResultRefreshCalls = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.localSnapshotGeneration = 0; + state.committedLocalSnapshotGeneration = 0; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + presetRequests.push(structuredClone(request)); + if (presetRequests.length === 1) return heldPreset.promise; + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + state.refresh = async () => { + postResultRefreshCalls += 1; + const generation = ++state.localSnapshotGeneration; + const snapshot = await postResultRefresh.promise; + state.localSnapshot = { host: HOST, observedAt: 3_000, snapshot }; + state.committedLocalSnapshotGeneration = generation; + state.localHealth = { state: "ready", changedAt: 3_000 }; + }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + + controller.rotateDial(presetDial, 2); + await settle(); + const refreshGeneration = ++state.localSnapshotGeneration; + const refresh = heldRefresh.promise.then((snapshot) => { + state.localSnapshot = { host: HOST, observedAt: 2_000, snapshot }; + state.committedLocalSnapshotGeneration = refreshGeneration; + state.localHealth = { state: "ready", changedAt: 2_000 }; + }); + state.refreshInFlight = refresh; + heldRefresh.resolve(modelSnapshot()); + await refresh; + state.refreshInFlight = undefined; + heldPreset.resolve({ modelId: "gpt-5.6-sol", reasoningEffort: "medium" }); + await settle(); + + assert.equal(presetRequests.length, 1, "queued successor waits for committed authority"); + assert.equal(postResultRefreshCalls, 1); + postResultRefresh.resolve(modelSnapshot("gpt-5.6-terra", "medium")); + await idle(controller, presetDial.id); + assert.deepEqual(presetRequests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"], + ["gpt-5.6-sol", "high"] + ]); + controller.unregisterDial(presetDial); +}); + +test("remote model preset requires capability and redraws the current registration after exact confirmation", async () => { + const controller = new DeckController(); + const dial = fakeDial("remote-model-preset"); + const state = probe(controller); + const remoteSnapshot = { + host: REMOTE_HOST, observedAt: 1_000, snapshot: modelSnapshot() + }; + const sent: unknown[] = []; + state.localHost = HOST; + state.targetHostId = REMOTE_HOST.hostId; + state.targetPlatform = REMOTE_HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.relayClient = { + currentHost: () => REMOTE_HOST, + currentHealth: () => ({ state: "ready", changedAt: 1_000 }), + currentSnapshot: () => remoteSnapshot, + supportsCurrentReadyCapability: () => true, + supportsCapabilityForSnapshot: (capability, hostId, platform) => + capability === "model-presets" && hostId === REMOTE_HOST.hostId && platform === REMOTE_HOST.platform, + async send(command) { + sent.push(structuredClone(command)); + remoteSnapshot.snapshot = modelSnapshot("gpt-5.6-sol", "medium"); + return { modelId: "gpt-5.6-sol", reasoningEffort: "medium" }; + } + }; + controller.registerDial(dial, modelPresetSettings()); + await settle(); + + controller.rotateDial(dial, 1); + await idle(controller, dial.id); + + assert.deepEqual(sent, [{ + kind: "model-preset", modelId: "gpt-5.6-sol", reasoningEffort: "medium", + includeUltra: false, includeModelPresetFeedback: true + }]); + assert.equal((dial.feedbackCalls.at(-1) as { title: string }).title, "MODEL PRESET 2/3"); + assert.equal((dial.feedbackCalls.at(-1) as { value: string }).value, "5.6 SOL"); + controller.unregisterDial(dial); +}); + +test("failed newer refresh degrades and blocks reasoning plus its dependent preset", async () => { + const controller = new DeckController(); + const presetDial = fakeDial("reasoning-refresh-failure"); + const state = probe(controller); + const heldReasoning = deferred(); + const heldRefresh = deferred(); + let presetCalls = 0; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.localSnapshotGeneration = 0; + state.committedLocalSnapshotGeneration = 0; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { return heldReasoning.promise; }, + async applyModelPreset(request) { + presetCalls += 1; + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(presetDial, modelPresetSettings()); + await settle(); + + const adjustment = state.adjustLocalReasoningFromRelay("increase", { includeUltra: true }); + await settle(); + state.localSnapshotGeneration += 1; + state.refreshInFlight = heldRefresh.promise.catch(() => { + state.localHealth = { + state: "degraded", reason: "local-bridge-unavailable", changedAt: 2_000 + }; + }); + controller.rotateDial(presetDial, 1); + heldReasoning.resolve({ outcome: "applied", reasoningEffort: "high" }); + await settle(); + heldRefresh.reject(new Error("refresh failed")); + + await assert.rejects(adjustment, /authoritative reasoning state is unavailable/i); + await idle(controller, presetDial.id); + assert.equal(state.localHealth.state, "degraded"); + assert.equal(presetCalls, 0); + assert.equal(presetDial.alerts, 1); + controller.unregisterDial(presetDial); +}); + +test("preset tickets preserve arrival order across independent dial queues", async () => { + const controller = new DeckController(); + const earlierDial = fakeDial("ticket-order-earlier"); + const laterDial = fakeDial("ticket-order-later"); + const state = probe(controller); + const heldDialWork = deferred(); + const requests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(earlierDial, modelPresetSettings()); + controller.registerDial(laterDial, modelPresetSettings()); + await settle(); + state.dials.get(earlierDial.id)!.queue.enqueue(() => heldDialWork.promise); + + controller.rotateDial(earlierDial, 1); + controller.rotateDial(laterDial, -1); + await settle(); + assert.equal(requests.length, 0, "later idle dial waits for the earlier accepted ticket"); + heldDialWork.resolve(); + await Promise.all([ + state.dials.get(earlierDial.id)!.queue.idle(), + state.dials.get(laterDial.id)!.queue.idle() + ]); + + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-sol", "medium"], + ["gpt-5.6-sol", "high"] + ]); + controller.unregisterDial(earlierDial); + controller.unregisterDial(laterDial); +}); + +test("canceling an earlier preset ticket advances a later dial without waiting on its local queue", async () => { + const controller = new DeckController(); + const canceledDial = fakeDial("ticket-canceled-earlier"); + const laterDial = fakeDial("ticket-after-cancel"); + const state = probe(controller); + const heldDialWork = deferred(); + const requests: ModelPresetRequest[] = []; + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.localSnapshot = { host: HOST, observedAt: 1_000, snapshot: modelSnapshot() }; + state.microBridge = { + async sendAgent() {}, + async applyModelPreset(request) { + requests.push(structuredClone(request)); + return { modelId: request.modelId, reasoningEffort: request.reasoningEffort }; + } + }; + controller.registerDial(canceledDial, modelPresetSettings()); + controller.registerDial(laterDial, modelPresetSettings()); + await settle(); + state.dials.get(canceledDial.id)!.queue.enqueue(() => heldDialWork.promise); + + controller.rotateDial(canceledDial, 1); + controller.rotateDial(laterDial, -1); + controller.unregisterDial(canceledDial); + await idle(controller, laterDial.id); + + assert.deepEqual(requests.map(({ modelId, reasoningEffort }) => [modelId, reasoningEffort]), [ + ["gpt-5.6-terra", "medium"] + ]); + assert.equal(state.modelReasoningMutationPending, 0); + heldDialWork.resolve(); + controller.unregisterDial(laterDial); +}); diff --git a/test/dial-action.test.ts b/test/dial-action.test.ts new file mode 100644 index 0000000..127d7c4 --- /dev/null +++ b/test/dial-action.test.ts @@ -0,0 +1,1381 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import type { DeckController } from "../src/controller.js"; +import { CodexDialAction } from "../src/dial-action.js"; +import { + expandDialPreset, isDialBindingId, JOYSTICK_DIRECTIONS, MICRO_SLOTS, normalizeDialSettings +} from "../src/dial-domain.js"; +import { OFFICIAL_KEYCAP_IDS } from "../src/keycaps.js"; + +const text = (path: string) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); +const CODEX_DIAL_ACTION_UUID = "com.simeo.codex-deck.codex-dial"; + +type ManifestAction = { + UUID: string; + Controllers?: string[]; + Encoder?: { + Icon?: string; + layout?: string; + TriggerDescription?: Record; + }; + PropertyInspectorPath?: string; +}; + +type ControllerCall = { method: string; action: unknown; payload?: unknown }; + +type FakeEvent = { target: FakeElement; preventDefault(): void }; + +class FakeElement { + readonly children: FakeElement[] = []; + readonly dataset: Record = {}; + readonly listeners = new Map void>>(); + readonly attributes = new Map(); + parentElement: FakeElement | null = null; + private currentValue = ""; + checked = false; + disabled = false; + draggable = false; + hidden = false; + type = ""; + textContent = ""; + className = ""; + maxLength = -1; + htmlFor = ""; + + constructor(readonly tagName: string, readonly id = "") {} + + get options(): FakeElement[] { + return this.descendants().filter(({ tagName }) => tagName === "OPTION"); + } + + get value(): string { return this.currentValue; } + + set value(value: string) { + const options = this.options; + this.currentValue = this.tagName === "SELECT" && options.length > 0 && + !options.some((option) => option.value === value) ? "" : value; + } + + appendChild(child: FakeElement): FakeElement { + child.parentElement = this; + this.children.push(child); + return child; + } + + append(...children: FakeElement[]): void { + for (const child of children) this.appendChild(child); + } + + replaceChildren(...children: FakeElement[]): void { + this.children.splice(0); + this.append(...children); + } + + addEventListener(name: string, listener: (event: FakeEvent) => void): void { + const listeners = this.listeners.get(name) ?? []; + listeners.push(listener); + this.listeners.set(name, listeners); + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + dispatch(name: string): void { + const event = { target: this, preventDefault() {} }; + for (const listener of this.listeners.get(name) ?? []) listener(event); + } + + descendants(): FakeElement[] { + return this.children.flatMap((child) => [child, ...child.descendants()]); + } +} + +class FakeDocument { + readonly elements = new Map(); + + constructor(source: string) { + const elementPattern = /<(select|input|button|div|section|label|p|textarea)[^>]*\bid=["']([^"']+)["'][^>]*>/gi; + for (const match of source.matchAll(elementPattern)) { + const tagName = match[1]?.toUpperCase(); + const id = match[2]; + if (tagName && id) this.elements.set(id, new FakeElement(tagName, id)); + } + } + + getElementById(id: string): FakeElement | null { + return this.elements.get(id) ?? null; + } + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName.toUpperCase()); + } +} + +type FakeSocket = { + url: string; + readyState: number; + sent: string[]; + closed: boolean; + open(): void; + close(): void; + message(payload: unknown): void; +}; + +async function inspectorHarness(): Promise<{ + document: FakeDocument; + sockets: FakeSocket[]; + connect: (...args: string[]) => void; + connectRaw: (...args: unknown[]) => void; + normalize: (input: unknown) => Record; +}> { + const source = await text("static/property-inspector/codex-dial.html"); + const script = source.match(/