From c80b9d275c857a90d9cd7311d836525c8cda4772 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 12:54:58 -0400 Subject: [PATCH 001/137] docs: design configurable Stream Deck Plus dials --- ...8-09-stream-deck-plus-codex-dial-design.md | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-stream-deck-plus-codex-dial-design.md 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..091a860 --- /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:** Draft for user review + +**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. From 80b95b436cb179fd8e9ba6ac48e1b5e3b20951c3 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 13:07:55 -0400 Subject: [PATCH 002/137] docs: plan Stream Deck Plus dial implementation --- .../2026-08-09-stream-deck-plus-codex-dial.md | 1334 +++++++++++++++++ ...8-09-stream-deck-plus-codex-dial-design.md | 2 +- 2 files changed, 1335 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-09-stream-deck-plus-codex-dial.md 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/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 index 091a860..24736b8 100644 --- 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 @@ -2,7 +2,7 @@ **Date:** 2026-08-09 -**Status:** Draft for user review +**Status:** Approved by user **Repository:** `dazer1234/codex-stream-deck` From 50b8d141b744a9f78e88236f6e252c4de8faf338 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 13:13:47 -0400 Subject: [PATCH 003/137] feat: add configurable Codex dial presets --- src/dial-domain.ts | 163 ++++++++++++++++++++++++++++++++++ src/dial-types.ts | 68 ++++++++++++++ test/dial-domain.test.ts | 187 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 src/dial-domain.ts create mode 100644 src/dial-types.ts create mode 100644 test/dial-domain.test.ts diff --git a/src/dial-domain.ts b/src/dial-domain.ts new file mode 100644 index 0000000..60d8882 --- /dev/null +++ b/src/dial-domain.ts @@ -0,0 +1,163 @@ +import { OFFICIAL_KEYCAP_IDS } from "./keycaps.js"; +import { + DIAL_FEEDBACK_MODES, + DIAL_PRESETS, + DIAL_SELECTOR_SOURCES, + type CodexDialSettings, + type DialBindingId, + type DialFeedbackMode, + type DialPreset, + type DialRotation, + type DialSelectorSource +} 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 staticLabel = typeof input.staticLabel === "string" + ? input.staticLabel.trim().slice(0, 40) + : undefined; + return { + version: 1, + preset: input.preset, + customized: input.customized === true, + rotation: normalizeRotation(input.rotation, fallback.rotation), + press: isDialBindingId(input.press, "press") ? input.press : fallback.press, + touchTap: isDialBindingId(input.touchTap, "touch") ? input.touchTap : "none", + feedback: isFeedback(input.feedback) ? input.feedback : fallback.feedback, + ...(staticLabel ? { staticLabel } : {}) + }; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPreset(value: unknown): value is DialPreset { + return typeof value === "string" && DIAL_PRESETS.includes(value as DialPreset); +} + +function isFeedback(value: unknown): value is DialFeedbackMode { + return typeof value === "string" && DIAL_FEEDBACK_MODES.includes(value as DialFeedbackMode); +} + +function isSelectorSource(value: unknown): value is DialSelectorSource { + return typeof value === "string" && DIAL_SELECTOR_SOURCES.includes(value as DialSelectorSource); +} + +function pairedPreset( + preset: DialPreset, + counterClockwise: DialBindingId, + clockwise: DialBindingId, + press: DialBindingId, + touchTap: DialBindingId, + feedback: DialFeedbackMode +): CodexDialSettings { + return { + version: 1, + preset, + customized: false, + rotation: { kind: "paired", counterClockwise, clockwise }, + press, + touchTap, + feedback + }; +} + +function selectorPreset( + preset: DialSelectorSource, + press: DialBindingId, + touchTap: DialBindingId, + feedback: DialFeedbackMode, + items: DialBindingId[] = [] +): CodexDialSettings { + return { + version: 1, + preset, + customized: false, + rotation: { kind: "selector", source: preset, wrap: true, items }, + press, + touchTap, + feedback + }; +} + +function normalizeRotation(value: unknown, fallback: DialRotation): DialRotation { + if (!record(value)) return fallback; + if (value.kind === "paired") { + return { + kind: "paired", + counterClockwise: isDialBindingId(value.counterClockwise, "rotation") + ? value.counterClockwise + : "none", + clockwise: isDialBindingId(value.clockwise, "rotation") ? value.clockwise : "none" + }; + } + if (value.kind === "selector" && isSelectorSource(value.source) && + typeof value.wrap === "boolean" && Array.isArray(value.items)) { + return { + kind: "selector", + source: value.source, + wrap: value.wrap, + items: value.items.filter((item): item is DialBindingId => + isDialBindingId(item, "selector")).slice(0, 30) + }; + } + return fallback; +} diff --git a/src/dial-types.ts b/src/dial-types.ts new file mode 100644 index 0000000..4ca58ab --- /dev/null +++ b/src/dial-types.ts @@ -0,0 +1,68 @@ +import type { JsonObject } from "@elgato/utils"; +import type { OfficialKeycapId } from "./keycaps.js"; +import type { MicroActionSlot, MicroDirection } from "./types.js"; + +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.${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 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; +}; diff --git a/test/dial-domain.test.ts b/test/dial-domain.test.ts new file mode 100644 index 0000000..2660d30 --- /dev/null +++ b/test/dial-domain.test.ts @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DEFAULT_ACTION_SELECTOR_ITEMS, + expandDialPreset, + isDialBindingId, + normalizeDialSettings +} from "../src/dial-domain.js"; + +test("status-focused presets expand to the approved independent bindings", () => { + const actionItems = [ + "micro.ACT06", "micro.ACT07", "micro.ACT08", "micro.ACT09", "micro.ACT10_ACT11", "micro.ACT12" + ]; + assert.deepEqual(DEFAULT_ACTION_SELECTOR_ITEMS, actionItems); + 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"), { + version: 1, + preset: "actions", + customized: false, + rotation: { + kind: "selector", source: "actions", wrap: true, items: actionItems + }, + press: "selector.activate", + touchTap: "keycap.SETUP", + feedback: "action" + }); + assert.deepEqual(expandDialPreset("navigation"), { + version: 1, + preset: "navigation", + customized: false, + rotation: { kind: "paired", counterClockwise: "joystick.left", clockwise: "joystick.right" }, + press: "joystick.up", + touchTap: "joystick.down", + feedback: "navigation" + }); + 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" + }); + assert.deepEqual(expandDialPreset("custom"), { + version: 1, + preset: "custom", + customized: false, + rotation: { kind: "paired", counterClockwise: "none", clockwise: "none" }, + press: "none", + touchTap: "none", + feedback: "static" + }); +}); + +test("binding validation accepts only exact allow-listed commands for each gesture", () => { + for (const binding of [ + "none", "reasoning.decrease", "reasoning.increase", "new-task", "host.toggle", + "usage.refresh", "usage.toggle-overview", "micro.ACT06", "micro.ACT10_ACT11", + "joystick.up", "joystick.left", "keycap.FAST", "keycap.MIND+" + ]) { + assert.equal(isDialBindingId(binding, "rotation"), true, binding); + } + assert.equal(isDialBindingId("selector.activate", "press"), true); + assert.equal(isDialBindingId("usage.rate-limit-reset", "press"), true); + + for (const binding of [ + "shell.rm", "micro.ACT13", "micro.ACT06.extra", "joystick.center", "joystick.up.extra", + "keycap.NOT_REAL", "keycap.FAST.extra", "usage.refresh.now", "", null + ]) { + assert.equal(isDialBindingId(binding, "press"), false, String(binding)); + } + assert.equal(isDialBindingId("selector.activate", "rotation"), false); + assert.equal(isDialBindingId("selector.activate", "touch"), false); + assert.equal(isDialBindingId("usage.rate-limit-reset", "rotation"), false); + assert.equal(isDialBindingId("usage.rate-limit-reset", "selector"), false); + assert.equal(isDialBindingId("usage.rate-limit-reset", "touch"), false); +}); + +test("malformed settings normalize to a safe preset and reject executable strings", () => { + assert.deepEqual(normalizeDialSettings({}), expandDialPreset("reasoning")); + assert.deepEqual( + normalizeDialSettings({ version: 2, preset: "usage" }), + expandDialPreset("reasoning") + ); + assert.deepEqual( + normalizeDialSettings({ version: 1, preset: "unknown" }), + 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"); +}); + +test("selector normalization validates structure, filters bindings, and caps items", () => { + const requestedItems: string[] = Array.from({ length: 35 }, (_, index) => + index % 2 === 0 ? "micro.ACT06" : "keycap.FAST" + ); + requestedItems.splice(2, 0, "shell.rm", "selector.activate", "usage.rate-limit-reset"); + const normalized = normalizeDialSettings({ + version: 1, + preset: "actions", + rotation: { kind: "selector", source: "actions", wrap: false, items: requestedItems }, + press: "shell.execute", + touchTap: "keycap.SETUP", + feedback: "action" + }); + assert.equal(normalized.rotation.kind, "selector"); + if (normalized.rotation.kind === "selector") { + assert.equal(normalized.rotation.source, "actions"); + assert.equal(normalized.rotation.wrap, false); + assert.equal(normalized.rotation.items.length, 30); + assert.equal(normalized.rotation.items.includes("selector.activate"), false); + assert.equal(normalized.rotation.items.includes("usage.rate-limit-reset"), false); + } + assert.equal(normalized.press, "selector.activate", "invalid press uses the preset fallback"); + + assert.deepEqual(normalizeDialSettings({ + version: 1, + preset: "usage", + rotation: { kind: "selector", source: "arbitrary", wrap: true, items: [] }, + press: "usage.toggle-overview", + touchTap: "usage.refresh", + feedback: "usage" + }).rotation, expandDialPreset("usage").rotation); + assert.deepEqual(normalizeDialSettings({ + version: 1, + preset: "agents", + rotation: { kind: "selector", source: "agents", wrap: "true", items: [] }, + press: "selector.activate", + touchTap: "keycap.TIME", + feedback: "agent" + }).rotation, expandDialPreset("agents").rotation); +}); + +test("normalization retains only exact customization and bounded non-empty labels", () => { + const normalized = normalizeDialSettings({ + version: 1, + preset: "custom", + customized: "true", + rotation: { kind: "paired", counterClockwise: "none", clockwise: "none" }, + press: "none", + touchTap: "none", + feedback: "not-real", + staticLabel: ` ${"x".repeat(45)} ` + }); + assert.equal(normalized.customized, false); + assert.equal(normalized.feedback, "static"); + assert.equal(normalized.staticLabel, "x".repeat(40)); + + const blank = normalizeDialSettings({ + ...normalized, + customized: true, + staticLabel: " " + }); + assert.equal(blank.customized, true); + assert.equal("staticLabel" in blank, false); +}); From fb5743732c4b7655fdb20f914b22d55d2d4e3d66 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 13:23:51 -0400 Subject: [PATCH 004/137] fix: harden Codex dial settings normalization --- src/dial-domain.ts | 78 +++++++++++------- src/dial-types.ts | 14 +++- test/dial-domain.test.ts | 172 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 32 deletions(-) diff --git a/src/dial-domain.ts b/src/dial-domain.ts index 60d8882..c98eb5a 100644 --- a/src/dial-domain.ts +++ b/src/dial-domain.ts @@ -11,11 +11,20 @@ import { type DialSelectorSource } 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 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}`) +); + +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); export function expandDialPreset(preset: DialPreset): CodexDialSettings { if (preset === "agents") { @@ -51,13 +60,9 @@ export function isDialBindingId( 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] - )); + (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; @@ -65,22 +70,29 @@ export function isDialBindingId( } export function normalizeDialSettings(input: unknown): CodexDialSettings { - if (!record(input) || input.version !== 1 || !isPreset(input.preset)) { + if (!record(input) || !hasOwn(input, "version") || input.version !== 1 || + !hasOwn(input, "preset") || !isPreset(input.preset)) { return expandDialPreset("reasoning"); } const fallback = expandDialPreset(input.preset); - const staticLabel = typeof input.staticLabel === "string" + const staticLabel = hasOwn(input, "staticLabel") && typeof input.staticLabel === "string" ? input.staticLabel.trim().slice(0, 40) : undefined; return { version: 1, preset: input.preset, - customized: input.customized === true, - rotation: normalizeRotation(input.rotation, fallback.rotation), - press: isDialBindingId(input.press, "press") ? input.press : fallback.press, - touchTap: isDialBindingId(input.touchTap, "touch") ? input.touchTap : "none", - feedback: isFeedback(input.feedback) ? input.feedback : fallback.feedback, + customized: hasOwn(input, "customized") && input.customized === true, + rotation: normalizeRotation(hasOwn(input, "rotation") ? input.rotation : undefined, fallback.rotation), + press: hasOwn(input, "press") && isDialBindingId(input.press, "press") + ? input.press + : fallback.press, + touchTap: hasOwn(input, "touchTap") && isDialBindingId(input.touchTap, "touch") + ? input.touchTap + : "none", + feedback: hasOwn(input, "feedback") && isFeedback(input.feedback) + ? input.feedback + : fallback.feedback, ...(staticLabel ? { staticLabel } : {}) }; } @@ -89,16 +101,20 @@ function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function hasOwn(value: Record, key: string): boolean { + return Object.hasOwn(value, key); +} + function isPreset(value: unknown): value is DialPreset { - return typeof value === "string" && DIAL_PRESETS.includes(value as DialPreset); + return typeof value === "string" && PRESET_IDS.has(value); } function isFeedback(value: unknown): value is DialFeedbackMode { - return typeof value === "string" && DIAL_FEEDBACK_MODES.includes(value as DialFeedbackMode); + return typeof value === "string" && FEEDBACK_MODE_IDS.has(value); } function isSelectorSource(value: unknown): value is DialSelectorSource { - return typeof value === "string" && DIAL_SELECTOR_SOURCES.includes(value as DialSelectorSource); + return typeof value === "string" && SELECTOR_SOURCE_IDS.has(value); } function pairedPreset( @@ -139,24 +155,30 @@ function selectorPreset( } function normalizeRotation(value: unknown, fallback: DialRotation): DialRotation { - if (!record(value)) return fallback; + if (!record(value) || !hasOwn(value, "kind")) return fallback; if (value.kind === "paired") { return { kind: "paired", - counterClockwise: isDialBindingId(value.counterClockwise, "rotation") + counterClockwise: hasOwn(value, "counterClockwise") && + isDialBindingId(value.counterClockwise, "rotation") ? value.counterClockwise : "none", - clockwise: isDialBindingId(value.clockwise, "rotation") ? value.clockwise : "none" + clockwise: hasOwn(value, "clockwise") && isDialBindingId(value.clockwise, "rotation") + ? value.clockwise + : "none" }; } - if (value.kind === "selector" && isSelectorSource(value.source) && - typeof value.wrap === "boolean" && Array.isArray(value.items)) { + if (value.kind === "selector" && hasOwn(value, "source") && isSelectorSource(value.source) && + hasOwn(value, "wrap") && typeof value.wrap === "boolean" && + hasOwn(value, "items") && Array.isArray(value.items)) { return { kind: "selector", source: value.source, wrap: value.wrap, - items: value.items.filter((item): item is DialBindingId => - isDialBindingId(item, "selector")).slice(0, 30) + items: value.source === "actions" + ? value.items.filter((item): item is DialBindingId => + isDialBindingId(item, "selector")).slice(0, 30) + : [] }; } return fallback; diff --git a/src/dial-types.ts b/src/dial-types.ts index 4ca58ab..79e0228 100644 --- a/src/dial-types.ts +++ b/src/dial-types.ts @@ -1,14 +1,20 @@ -import type { JsonObject } from "@elgato/utils"; import type { OfficialKeycapId } from "./keycaps.js"; import type { MicroActionSlot, MicroDirection } from "./types.js"; -export const DIAL_PRESETS = ["reasoning", "agents", "actions", "navigation", "usage", "custom"] as const; +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"] as const +); export type DialPreset = typeof DIAL_PRESETS[number]; -export const DIAL_FEEDBACK_MODES = ["auto", "reasoning", "agent", "action", "navigation", "usage", "static"] as const; +export const DIAL_FEEDBACK_MODES = Object.freeze( + ["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 const DIAL_SELECTOR_SOURCES = Object.freeze(["agents", "actions", "usage"] as const); export type DialSelectorSource = typeof DIAL_SELECTOR_SOURCES[number]; export type DialBindingId = diff --git a/test/dial-domain.test.ts b/test/dial-domain.test.ts index 2660d30..6d3e41d 100644 --- a/test/dial-domain.test.ts +++ b/test/dial-domain.test.ts @@ -2,10 +2,43 @@ import assert from "node:assert/strict"; import test from "node:test"; import { DEFAULT_ACTION_SELECTOR_ITEMS, + JOYSTICK_DIRECTIONS, + MICRO_SLOTS, expandDialPreset, isDialBindingId, normalizeDialSettings } from "../src/dial-domain.js"; +import { + DIAL_FEEDBACK_MODES, + DIAL_PRESETS, + DIAL_SELECTOR_SOURCES, + type DialPreset +} from "../src/dial-types.js"; + +function withInheritedProperty( + values: Record, inheritedKey: string +): Record { + const inherited = Object.create({ [inheritedKey]: values[inheritedKey] }) as Record; + for (const [key, value] of Object.entries(values)) { + if (key !== inheritedKey) inherited[key] = value; + } + return inherited; +} + +function assertFrozenCatalog(catalog: readonly string[]): void { + const mutable = catalog as string[]; + const originalLength = mutable.length; + let mutationThrew = false; + try { + mutable.push("shell.rm"); + } catch (error) { + mutationThrew = error instanceof TypeError; + } finally { + if (!Object.isFrozen(catalog)) mutable.length = originalLength; + } + assert.equal(Object.isFrozen(catalog), true); + assert.equal(mutationThrew, true); +} test("status-focused presets expand to the approved independent bindings", () => { const actionItems = [ @@ -94,6 +127,24 @@ test("binding validation accepts only exact allow-listed commands for each gestu assert.equal(isDialBindingId("usage.rate-limit-reset", "touch"), false); }); +test("validation and preset catalogs are frozen against runtime authority changes", () => { + for (const catalog of [ + DIAL_PRESETS, + DIAL_FEEDBACK_MODES, + DIAL_SELECTOR_SOURCES, + MICRO_SLOTS, + JOYSTICK_DIRECTIONS, + DEFAULT_ACTION_SELECTOR_ITEMS + ]) { + assertFrozenCatalog(catalog); + } + assert.equal(expandDialPreset("shell.rm" as DialPreset).preset, "reasoning"); + assert.equal(isDialBindingId("shell.rm"), false); + assert.equal(isDialBindingId("micro.shell.rm"), false); + assert.equal(isDialBindingId("joystick.shell.rm"), false); + assert.equal(isDialBindingId("keycap.shell.rm"), false); +}); + test("malformed settings normalize to a safe preset and reject executable strings", () => { assert.deepEqual(normalizeDialSettings({}), expandDialPreset("reasoning")); assert.deepEqual( @@ -121,6 +172,92 @@ test("malformed settings normalize to a safe preset and reject executable string assert.equal(normalized.touchTap, "none", "reset hold cannot be bound to touch tap"); }); +test("normalization ignores inherited top-level settings properties", () => { + const values = { + version: 1, + preset: "custom", + customized: true, + rotation: { + kind: "paired", counterClockwise: "reasoning.decrease", clockwise: "reasoning.increase" + }, + press: "usage.rate-limit-reset", + touchTap: "keycap.FAST", + feedback: "reasoning" + }; + assert.deepEqual(normalizeDialSettings(values), values); + + for (const key of ["version", "preset"]) { + assert.deepEqual( + normalizeDialSettings(withInheritedProperty(values, key)), + expandDialPreset("reasoning"), + `inherited ${key}` + ); + } + + const expectedByKey = { + customized: false, + rotation: expandDialPreset("custom").rotation, + press: "none", + touchTap: "none", + feedback: "static" + } as const; + for (const key of ["customized", "rotation", "press", "touchTap", "feedback"] as const) { + assert.deepEqual( + normalizeDialSettings(withInheritedProperty(values, key))[key], + expectedByKey[key], + `inherited ${key}` + ); + } +}); + +test("normalization ignores inherited nested rotation properties", () => { + const paired = { + kind: "paired", counterClockwise: "reasoning.decrease", clockwise: "reasoning.increase" + }; + const pairedExpected = { + kind: "paired", counterClockwise: "reasoning.decrease", clockwise: "reasoning.increase" + }; + assert.deepEqual(normalizeDialSettings({ + version: 1, preset: "custom", rotation: paired, press: "none", touchTap: "none", feedback: "static" + }).rotation, pairedExpected); + assert.deepEqual(normalizeDialSettings({ + version: 1, + preset: "custom", + rotation: withInheritedProperty(paired, "kind"), + press: "none", + touchTap: "none", + feedback: "static" + }).rotation, expandDialPreset("custom").rotation); + assert.deepEqual(normalizeDialSettings({ + version: 1, + preset: "custom", + rotation: withInheritedProperty(paired, "counterClockwise"), + press: "none", + touchTap: "none", + feedback: "static" + }).rotation, { ...pairedExpected, counterClockwise: "none" }); + assert.deepEqual(normalizeDialSettings({ + version: 1, + preset: "custom", + rotation: withInheritedProperty(paired, "clockwise"), + press: "none", + touchTap: "none", + feedback: "static" + }).rotation, { ...pairedExpected, clockwise: "none" }); + + const selector = { kind: "selector", source: "actions", wrap: false, items: ["keycap.FAST"] }; + for (const key of ["kind", "source", "wrap", "items"]) { + assert.deepEqual(normalizeDialSettings({ + version: 1, + preset: "actions", + rotation: withInheritedProperty(selector, key), + press: "selector.activate", + touchTap: "keycap.SETUP", + feedback: "action" + }).rotation, expandDialPreset("actions").rotation, `inherited selector ${key}`); + } +}); + test("selector normalization validates structure, filters bindings, and caps items", () => { const requestedItems: string[] = Array.from({ length: 35 }, (_, index) => index % 2 === 0 ? "micro.ACT06" : "keycap.FAST" @@ -162,6 +299,41 @@ test("selector normalization validates structure, filters bindings, and caps ite }).rotation, expandDialPreset("agents").rotation); }); +test("only action selectors persist configured items", () => { + const agents = normalizeDialSettings({ + version: 1, + preset: "agents", + rotation: { kind: "selector", source: "agents", wrap: true, items: ["keycap.FAST"] }, + press: "selector.activate", + touchTap: "keycap.TIME", + feedback: "agent" + }); + const usage = normalizeDialSettings({ + version: 1, + preset: "usage", + rotation: { kind: "selector", source: "usage", wrap: true, items: ["keycap.FAST"] }, + press: "usage.toggle-overview", + touchTap: "usage.refresh", + feedback: "usage" + }); + const actions = normalizeDialSettings({ + version: 1, + preset: "actions", + rotation: { + kind: "selector", source: "actions", wrap: true, items: ["keycap.FAST", "shell.rm"] + }, + press: "selector.activate", + touchTap: "keycap.SETUP", + feedback: "action" + }); + + assert.deepEqual(agents.rotation, { kind: "selector", source: "agents", wrap: true, items: [] }); + assert.deepEqual(usage.rotation, { kind: "selector", source: "usage", wrap: true, items: [] }); + assert.deepEqual(actions.rotation, { + kind: "selector", source: "actions", wrap: true, items: ["keycap.FAST"] + }); +}); + test("normalization retains only exact customization and bounded non-empty labels", () => { const normalized = normalizeDialSettings({ version: 1, From 5f641648db4834edd74b6740b2a507544de4b871 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 13:35:06 -0400 Subject: [PATCH 005/137] feat: add dial selectors and live feedback model --- src/dial-domain.ts | 402 +++++++++++++++++++++++++++++++++++++++ src/dial-types.ts | 27 +++ test/dial-domain.test.ts | 368 ++++++++++++++++++++++++++++++++++- 3 files changed, 795 insertions(+), 2 deletions(-) diff --git a/src/dial-domain.ts b/src/dial-domain.ts index c98eb5a..2e7c7e1 100644 --- a/src/dial-domain.ts +++ b/src/dial-domain.ts @@ -5,9 +5,13 @@ import { DIAL_SELECTOR_SOURCES, type CodexDialSettings, type DialBindingId, + type DialFeedback, type DialFeedbackMode, type DialPreset, + type DialRuntimeState, + type DialRuntimeView, type DialRotation, + type DialSelectorItem, type DialSelectorSource } from "./dial-types.js"; @@ -25,6 +29,128 @@ 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) => ({ + id: agent.identity, + label: agent.title, + 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, + label: cleanActionLabel(view.actionLabels[binding]) ?? humanBindingLabel(binding), + binding + })); +} + +export function reconcileSelector( + state: DialRuntimeState, + items: readonly DialSelectorItem[] +): DialRuntimeState { + if (items.length === 0) { + const { selectedId: _selectedId, ...cleared } = state; + return { ...cleared }; + } + const selectedId = items.some((item) => item.id === state.selectedId) + ? state.selectedId + : items[0]!.id; + return { ...state, selectedId }; +} + +export function reduceDialRotation( + settings: CodexDialSettings, + state: DialRuntimeState, + view: DialRuntimeView, + ticks: number +): { state: DialRuntimeState; bindings: DialBindingId[] } { + if (!Number.isFinite(ticks) || !Number.isInteger(ticks) || ticks === 0) { + 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 = Math.max(0, items.findIndex((item) => item.id === reconciled.selectedId)); + const nextIndex = settings.rotation.wrap + ? modulo(currentIndex + ticks, items.length) + : Math.min(items.length - 1, Math.max(0, currentIndex + ticks)); + const selectedId = items[nextIndex]!.id; + return { + state: { + ...reconciled, + selectedId, + ...(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 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) + : staticFeedback(settings); + return view.health === "ready" ? live : healthFeedback(live.title, view.health); +} export function expandDialPreset(preset: DialPreset): CodexDialSettings { if (preset === "agents") { @@ -183,3 +309,279 @@ function normalizeRotation(value: unknown, fallback: DialRotation): DialRotation } return fallback; } + +function cleanActionLabel(value: string | undefined): string | undefined { + if (value == null) return undefined; + const clean = value.replace(/\s+/g, " ").trim(); + return clean || undefined; +} + +function humanBindingLabel(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 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 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 item = selectedItem(settings, state, view); + if (!item) { + return feedback("AGENT", "UNAVAILABLE", "NO ACTIVE AGENTS", 0, DIAL_ACCENTS.muted); + } + const agent = view.agents.find((candidate) => candidate.identity === item.id); + if (!agent) { + return feedback("AGENT", "UNAVAILABLE", "NO ACTIVE AGENTS", 0, DIAL_ACCENTS.muted); + } + const context = finitePercent(agent.contextUsedPercent); + const detail = context == null + ? agent.status + : `${agent.status} • ${Math.round(context)}% context`; + return feedback( + `AGENT ${agent.id}`, + agent.title, + detail, + context ?? 0, + agentAccent(agent.status) + ); +} + +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 (!item) { + return feedback("ACTION", "UNAVAILABLE", "NO ACTIONS CONFIGURED", 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, 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 hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + if (hours === 0) return `RESETS IN ${remainder}M`; + if (remainder === 0) return `RESETS IN ${hours}H`; + return `RESETS IN ${hours}H ${remainder}M`; +} + +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(); + if (uppercase.length <= maximum) return uppercase; + return `${uppercase.slice(0, maximum - 1).trimEnd()}…`; +} diff --git a/src/dial-types.ts b/src/dial-types.ts index 79e0228..131236a 100644 --- a/src/dial-types.ts +++ b/src/dial-types.ts @@ -65,6 +65,33 @@ export type DialRuntimeState = { usageOverview: boolean; }; +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 | null; + weeklyRemaining: number | null; +}; + +export type DialRuntimeView = { + health: "ready" | "degraded" | "offline" | "connecting"; + reasoningEffort?: string; + agents: DialRuntimeAgent[]; + actionLabels: Partial>; + usage?: DialRuntimeUsage; + now: number; +}; + export type DialFeedback = { title: string; value: string; diff --git a/test/dial-domain.test.ts b/test/dial-domain.test.ts index 6d3e41d..b449db1 100644 --- a/test/dial-domain.test.ts +++ b/test/dial-domain.test.ts @@ -4,15 +4,24 @@ import { DEFAULT_ACTION_SELECTOR_ITEMS, JOYSTICK_DIRECTIONS, MICRO_SLOTS, + deriveDialFeedback, expandDialPreset, + initialDialRuntimeState, isDialBindingId, - normalizeDialSettings + normalizeDialSettings, + reconcileSelector, + reduceDialRotation, + selectedItem, + selectorItems } from "../src/dial-domain.js"; import { DIAL_FEEDBACK_MODES, DIAL_PRESETS, DIAL_SELECTOR_SOURCES, - type DialPreset + type DialBindingId, + type CodexDialSettings, + type DialPreset, + type DialRuntimeView } from "../src/dial-types.js"; function withInheritedProperty( @@ -40,6 +49,361 @@ function assertFrozenCatalog(catalog: readonly string[]): void { assert.equal(mutationThrew, true); } +const RUNTIME_VIEW: DialRuntimeView = { + health: "ready", + reasoningEffort: "high", + agents: [ + { + id: 1, + identity: "rollout-alpha", + threadKey: "thread-alpha", + title: "Alpha task", + status: "thinking", + contextUsedPercent: 42 + }, + { + id: 4, + identity: "rollout-delta", + threadKey: "thread-delta", + title: "Delta task", + status: "idle" + } + ], + actionLabels: { + "micro.ACT06": "Approve change", + "keycap.FAST": "Fast mode" + }, + usage: { + mode: "five-hour", + remainingPercent: 72, + resetsAt: 10_000, + observedAt: 1_000, + fiveHourRemaining: 72, + weeklyRemaining: 41 + }, + now: 1_000 +}; + +function actionSelector( + items: DialBindingId[] = [] +): CodexDialSettings { + return normalizeDialSettings({ + ...expandDialPreset("actions"), + rotation: { kind: "selector", source: "actions", wrap: true, items } + }); +} + +test("runtime selectors expose occupied agents, exact usage choices, and ordered safe actions", () => { + const agentSettings = expandDialPreset("agents"); + assert.deepEqual(selectorItems(agentSettings, RUNTIME_VIEW), [ + { + id: "rollout-alpha", + label: "Alpha task", + detail: "thinking", + agentSlot: 1, + threadKey: "thread-alpha" + }, + { + id: "rollout-delta", + label: "Delta task", + detail: "idle", + agentSlot: 4, + threadKey: "thread-delta" + } + ]); + assert.deepEqual(selectorItems(expandDialPreset("usage"), RUNTIME_VIEW), [ + { id: "auto", label: "Automatic" }, + { id: "five-hour", label: "5 hours" }, + { id: "weekly", label: "Weekly" } + ]); + + const actions = actionSelector(["keycap.FAST", "micro.ACT06", "joystick.left", "new-task"]); + assert.deepEqual(selectorItems(actions, RUNTIME_VIEW), [ + { id: "keycap.FAST", label: "Fast mode", binding: "keycap.FAST" }, + { id: "micro.ACT06", label: "Approve change", binding: "micro.ACT06" }, + { id: "joystick.left", label: "Joystick Left", binding: "joystick.left" }, + { id: "new-task", label: "New Task", binding: "new-task" } + ]); +}); + +test("selector reconciliation preserves stable identity across reorder and handles disappearance", () => { + const original = selectorItems(expandDialPreset("agents"), RUNTIME_VIEW); + assert.deepEqual(reconcileSelector(initialDialRuntimeState(), original), { + usageMode: "auto", + usageOverview: false, + selectedId: "rollout-alpha" + }); + + const selectedDelta = { + ...initialDialRuntimeState(), + selectedId: "rollout-delta" + }; + const reordered = selectorItems(expandDialPreset("agents"), { + ...RUNTIME_VIEW, + agents: [...RUNTIME_VIEW.agents].reverse() + }); + assert.equal(reconcileSelector(selectedDelta, reordered).selectedId, "rollout-delta"); + assert.equal(reconcileSelector(selectedDelta, original.slice(0, 1)).selectedId, "rollout-alpha"); + assert.deepEqual(reconcileSelector(selectedDelta, []), { + usageMode: "auto", + usageOverview: false + }); +}); + +test("preset and runtime factories return independent mutable structures", () => { + const firstPreset = expandDialPreset("actions"); + const secondPreset = expandDialPreset("actions"); + assert.notEqual(firstPreset, secondPreset); + assert.notEqual(firstPreset.rotation, secondPreset.rotation); + if (firstPreset.rotation.kind === "selector" && secondPreset.rotation.kind === "selector") { + assert.notEqual(firstPreset.rotation.items, secondPreset.rotation.items); + firstPreset.rotation.items.push("keycap.FAST"); + assert.equal(secondPreset.rotation.items.includes("keycap.FAST"), false); + } + + const firstState = initialDialRuntimeState(); + const secondState = initialDialRuntimeState(); + assert.notEqual(firstState, secondState); + firstState.usageMode = "weekly"; + firstState.usageOverview = true; + assert.deepEqual(secondState, { usageMode: "auto", usageOverview: false }); +}); + +test("paired rotation emits one binding for every physical detent in both directions", () => { + const settings = expandDialPreset("reasoning"); + const state = initialDialRuntimeState(); + assert.deepEqual(reduceDialRotation(settings, state, RUNTIME_VIEW, -3), { + state, + bindings: ["reasoning.decrease", "reasoning.decrease", "reasoning.decrease"] + }); + assert.deepEqual(reduceDialRotation(settings, state, RUNTIME_VIEW, 2), { + state, + bindings: ["reasoning.increase", "reasoning.increase"] + }); + assert.equal(reduceDialRotation(settings, state, RUNTIME_VIEW, 1_001).bindings.length, 1_001); +}); + +test("selector rotation previews without dispatch and wraps or clamps as configured", () => { + const usage = expandDialPreset("usage"); + const weekly = { + ...initialDialRuntimeState(), + selectedId: "weekly", + usageMode: "weekly" as const + }; + const wrapped = reduceDialRotation(usage, weekly, RUNTIME_VIEW, 1); + assert.deepEqual(wrapped.bindings, []); + assert.deepEqual(wrapped.state, { + selectedId: "auto", + usageMode: "auto", + usageOverview: false + }); + + const clampedSettings = normalizeDialSettings({ + ...usage, + rotation: { kind: "selector", source: "usage", wrap: false, items: [] } + }); + assert.deepEqual(reduceDialRotation(clampedSettings, weekly, RUNTIME_VIEW, 3), { + state: weekly, + bindings: [] + }); + assert.equal( + reduceDialRotation(usage, { ...weekly, selectedId: "auto", usageMode: "auto" }, RUNTIME_VIEW, -1) + .state.selectedId, + "weekly" + ); +}); + +test("agent selector rotation skips absent slots and selectedItem reconciles stable identity", () => { + const agents = expandDialPreset("agents"); + const initial = reduceDialRotation(agents, initialDialRuntimeState(), RUNTIME_VIEW, 1); + assert.deepEqual(initial.bindings, []); + assert.equal(initial.state.selectedId, "rollout-delta"); + assert.equal(selectedItem(agents, initial.state, RUNTIME_VIEW)?.threadKey, "thread-delta"); + + const reorderedView = { ...RUNTIME_VIEW, agents: [...RUNTIME_VIEW.agents].reverse() }; + assert.equal(selectedItem(agents, initial.state, reorderedView)?.id, "rollout-delta"); + assert.equal(selectedItem(agents, initialDialRuntimeState(), { ...RUNTIME_VIEW, agents: [] }), undefined); +}); + +test("empty selectors and invalid, non-finite, fractional, or zero ticks are no-ops", () => { + const state = { ...initialDialRuntimeState(), selectedId: "missing" }; + const emptyView = { ...RUNTIME_VIEW, agents: [] }; + assert.deepEqual(reduceDialRotation(expandDialPreset("agents"), state, emptyView, 2), { + state: { usageMode: "auto", usageOverview: false }, + bindings: [] + }); + for (const ticks of [0, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, 1.5]) { + const result = reduceDialRotation(expandDialPreset("reasoning"), state, RUNTIME_VIEW, ticks); + assert.equal(result.state, state, String(ticks)); + assert.deepEqual(result.bindings, [], String(ticks)); + } +}); + +test("reasoning feedback reports the live effort and never infers an absent value", () => { + assert.deepEqual( + deriveDialFeedback(expandDialPreset("reasoning"), initialDialRuntimeState(), RUNTIME_VIEW), + { + title: "REASONING", + value: "HIGH", + detail: "TURN TO ADJUST", + indicator: 75, + accent: "#1683FF" + } + ); + assert.deepEqual( + deriveDialFeedback( + expandDialPreset("reasoning"), + initialDialRuntimeState(), + { ...RUNTIME_VIEW, reasoningEffort: undefined } + ), + { + title: "REASONING", + value: "UNAVAILABLE", + detail: "LIVE VALUE NOT REPORTED", + indicator: 0, + accent: "#707B85" + } + ); +}); + +test("agent and action feedback use reconciled selections and bounded live labels", () => { + const agentState = { ...initialDialRuntimeState(), selectedId: "rollout-alpha" }; + assert.deepEqual(deriveDialFeedback(expandDialPreset("agents"), agentState, RUNTIME_VIEW), { + title: "AGENT 1", + value: "ALPHA TASK", + detail: "THINKING • 42% CONTEXT", + indicator: 42, + accent: "#1683FF" + }); + + const settings = actionSelector(["keycap.FAST", "micro.ACT06"]); + assert.deepEqual(deriveDialFeedback(settings, initialDialRuntimeState(), RUNTIME_VIEW), { + title: "ACTION 1/2", + value: "FAST MODE", + detail: "PRESS TO RUN", + indicator: 50, + accent: "#4CE0C2" + }); + + const longLabelView = { + ...RUNTIME_VIEW, + actionLabels: { "keycap.FAST": " a very long\nlabel that cannot fit on the touchscreen " } + }; + const bounded = deriveDialFeedback( + actionSelector(["keycap.FAST"]), initialDialRuntimeState(), longLabelView + ); + assert.equal(bounded.value, "A VERY LONG LABEL THAT…"); + assert.equal(bounded.value.length <= 24, true); +}); + +test("navigation and static feedback are explicit, while auto follows the rotation source", () => { + assert.deepEqual( + deriveDialFeedback(expandDialPreset("navigation"), initialDialRuntimeState(), RUNTIME_VIEW), + { + title: "NAVIGATION", + value: "BACK / FORWARD", + detail: "TURN LEFT / RIGHT", + indicator: 50, + accent: "#1683FF" + } + ); + assert.deepEqual( + deriveDialFeedback( + normalizeDialSettings({ ...expandDialPreset("custom"), staticLabel: "Desk control" }), + initialDialRuntimeState(), + RUNTIME_VIEW + ), + { + title: "CUSTOM", + value: "DESK CONTROL", + detail: "READY", + indicator: 0, + accent: "#707B85" + } + ); + + const autoAgents = normalizeDialSettings({ ...expandDialPreset("agents"), feedback: "auto" }); + assert.equal(deriveDialFeedback(autoAgents, initialDialRuntimeState(), RUNTIME_VIEW).title, "AGENT 1"); + + const autoUsagePreset = normalizeDialSettings({ + ...expandDialPreset("usage"), + feedback: "auto", + rotation: { kind: "paired", counterClockwise: "none", clockwise: "none" } + }); + assert.equal( + deriveDialFeedback(autoUsagePreset, initialDialRuntimeState(), RUNTIME_VIEW).title, + "USAGE • 5 HOURS" + ); +}); + +test("usage feedback reports percent left, reset countdown, and both overview windows", () => { + const view: DialRuntimeView = { + ...RUNTIME_VIEW, + now: 1_000, + usage: { + ...RUNTIME_VIEW.usage!, + resetsAt: 9_001_000 + } + }; + const state = { + ...initialDialRuntimeState(), + selectedId: "five-hour", + usageMode: "five-hour" as const + }; + assert.deepEqual(deriveDialFeedback(expandDialPreset("usage"), state, view), { + title: "USAGE • 5 HOURS", + value: "72% LEFT", + detail: "RESETS IN 2H 30M", + indicator: 72, + accent: "#35D86B" + }); + assert.deepEqual(deriveDialFeedback( + expandDialPreset("usage"), + { ...state, usageOverview: true }, + view + ), { + title: "USAGE OVERVIEW", + value: "5H 72% • WK 41%", + detail: "PRESS TO CLOSE", + indicator: 72, + accent: "#35D86B" + }); + + assert.deepEqual(deriveDialFeedback( + expandDialPreset("usage"), + { ...state, selectedId: "weekly", usageMode: "weekly" }, + { ...view, usage: { ...view.usage!, weeklyRemaining: null } } + ), { + title: "USAGE • WEEKLY", + value: "UNAVAILABLE", + detail: "LIVE VALUE NOT REPORTED", + indicator: 0, + accent: "#707B85" + }); +}); + +test("feedback replaces live values with honest offline, connecting, and degraded states", () => { + const expected = { + offline: ["OFFLINE", "LIVE DATA UNAVAILABLE", "#FF4B61"], + connecting: ["CONNECTING", "WAITING FOR LIVE DATA", "#FF9A3D"], + degraded: ["DEGRADED", "LIVE DATA MAY BE STALE", "#FF9A3D"] + } as const; + for (const health of ["offline", "connecting", "degraded"] as const) { + const feedback = deriveDialFeedback( + expandDialPreset("reasoning"), + initialDialRuntimeState(), + { ...RUNTIME_VIEW, health } + ); + assert.deepEqual(feedback, { + title: "REASONING", + value: expected[health][0], + detail: expected[health][1], + indicator: 0, + accent: expected[health][2] + }); + } +}); + test("status-focused presets expand to the approved independent bindings", () => { const actionItems = [ "micro.ACT06", "micro.ACT07", "micro.ACT08", "micro.ACT09", "micro.ACT10_ACT11", "micro.ACT12" From 855a5850c1db379e10099e143f74a493759efd77 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 13:39:49 -0400 Subject: [PATCH 006/137] fix: align dial usage runtime contract --- src/dial-domain.ts | 6 +++--- src/dial-types.ts | 8 ++++---- test/dial-domain.test.ts | 42 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/dial-domain.ts b/src/dial-domain.ts index 2e7c7e1..dba0087 100644 --- a/src/dial-domain.ts +++ b/src/dial-domain.ts @@ -58,8 +58,8 @@ export function selectorItems( } if (settings.rotation.source === "usage") { return [ - { id: "auto", label: "Automatic" }, - { id: "five-hour", label: "5 hours" }, + { id: "auto", label: "Auto" }, + { id: "five-hour", label: "5h" }, { id: "weekly", label: "Weekly" } ]; } @@ -527,7 +527,7 @@ function percentOrDash(value: number | undefined): string { return value == null ? "—" : `${Math.round(value)}%`; } -function resetCountdown(resetsAt: number | null, now: number): string { +function resetCountdown(resetsAt: number | null | undefined, now: number): string { if (resetsAt == null || !Number.isFinite(resetsAt) || !Number.isFinite(now)) { return "RESET UNAVAILABLE"; } diff --git a/src/dial-types.ts b/src/dial-types.ts index 131236a..b4badab 100644 --- a/src/dial-types.ts +++ b/src/dial-types.ts @@ -77,10 +77,10 @@ export type DialRuntimeAgent = { export type DialRuntimeUsage = { mode: "auto" | "five-hour" | "weekly"; remainingPercent?: number; - resetsAt: number | null; - observedAt: number; - fiveHourRemaining: number | null; - weeklyRemaining: number | null; + resetsAt?: number | null; + observedAt?: number; + fiveHourRemaining?: number; + weeklyRemaining?: number; }; export type DialRuntimeView = { diff --git a/test/dial-domain.test.ts b/test/dial-domain.test.ts index b449db1..267305e 100644 --- a/test/dial-domain.test.ts +++ b/test/dial-domain.test.ts @@ -112,8 +112,8 @@ test("runtime selectors expose occupied agents, exact usage choices, and ordered } ]); assert.deepEqual(selectorItems(expandDialPreset("usage"), RUNTIME_VIEW), [ - { id: "auto", label: "Automatic" }, - { id: "five-hour", label: "5 hours" }, + { id: "auto", label: "Auto" }, + { id: "five-hour", label: "5h" }, { id: "weekly", label: "Weekly" } ]); @@ -372,7 +372,7 @@ test("usage feedback reports percent left, reset countdown, and both overview wi assert.deepEqual(deriveDialFeedback( expandDialPreset("usage"), { ...state, selectedId: "weekly", usageMode: "weekly" }, - { ...view, usage: { ...view.usage!, weeklyRemaining: null } } + { ...view, usage: { ...view.usage!, weeklyRemaining: undefined } } ), { title: "USAGE • WEEKLY", value: "UNAVAILABLE", @@ -382,6 +382,42 @@ test("usage feedback reports percent left, reset countdown, and both overview wi }); }); +test("partial usage runtime views remain honest and accept a nullable reset time", () => { + const partialView: DialRuntimeView = { + ...RUNTIME_VIEW, + usage: { mode: "weekly" } + }; + assert.deepEqual(deriveDialFeedback( + expandDialPreset("usage"), + { ...initialDialRuntimeState(), selectedId: "weekly", usageMode: "weekly" }, + partialView + ), { + title: "USAGE • WEEKLY", + value: "UNAVAILABLE", + detail: "LIVE VALUE NOT REPORTED", + indicator: 0, + accent: "#707B85" + }); + + const nullableResetView: DialRuntimeView = { + ...RUNTIME_VIEW, + usage: { + mode: "five-hour", + remainingPercent: 72, + resetsAt: null, + fiveHourRemaining: 72 + } + }; + assert.equal( + deriveDialFeedback( + expandDialPreset("usage"), + { ...initialDialRuntimeState(), selectedId: "five-hour", usageMode: "five-hour" }, + nullableResetView + ).detail, + "RESET UNAVAILABLE" + ); +}); + test("feedback replaces live values with honest offline, connecting, and degraded states", () => { const expected = { offline: ["OFFLINE", "LIVE DATA UNAVAILABLE", "#FF4B61"], From 6839f3ac37448a52f6e78520f39551cd5df155f7 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 13:51:22 -0400 Subject: [PATCH 007/137] fix: harden dial selector boundaries --- src/dial-domain.ts | 48 ++++++++++------ test/dial-domain.test.ts | 116 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 139 insertions(+), 25 deletions(-) diff --git a/src/dial-domain.ts b/src/dial-domain.ts index dba0087..9fa7243 100644 --- a/src/dial-domain.ts +++ b/src/dial-domain.ts @@ -29,6 +29,9 @@ 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); +// Stream Deck dial events are far smaller; this rejects corrupted counts while preserving every +// detent from any physically plausible SDK event. +const MAX_DIAL_TICKS_PER_EVENT = 4_096; const DIAL_ACCENTS = Object.freeze({ blue: "#1683FF", green: "#35D86B", @@ -48,13 +51,16 @@ export function selectorItems( ): DialSelectorItem[] { if (settings.rotation.kind !== "selector") return []; if (settings.rotation.source === "agents") { - return view.agents.map((agent) => ({ - id: agent.identity, - label: agent.title, - detail: agent.status, - agentSlot: agent.id, - threadKey: agent.threadKey - })); + 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 [ @@ -90,7 +96,7 @@ export function reduceDialRotation( view: DialRuntimeView, ticks: number ): { state: DialRuntimeState; bindings: DialBindingId[] } { - if (!Number.isFinite(ticks) || !Number.isInteger(ticks) || ticks === 0) { + if (!Number.isSafeInteger(ticks) || ticks === 0 || Math.abs(ticks) > MAX_DIAL_TICKS_PER_EVENT) { return { state, bindings: [] }; } if (settings.rotation.kind === "paired") { @@ -107,7 +113,7 @@ export function reduceDialRotation( if (items.length === 0) return { state: reconciled, bindings: [] }; const currentIndex = Math.max(0, items.findIndex((item) => item.id === reconciled.selectedId)); const nextIndex = settings.rotation.wrap - ? modulo(currentIndex + ticks, items.length) + ? modulo(currentIndex + (ticks % items.length), items.length) : Math.min(items.length - 1, Math.max(0, currentIndex + ticks)); const selectedId = items[nextIndex]!.id; return { @@ -303,7 +309,9 @@ function normalizeRotation(value: unknown, fallback: DialRotation): DialRotation wrap: value.wrap, items: value.source === "actions" ? value.items.filter((item): item is DialBindingId => - isDialBindingId(item, "selector")).slice(0, 30) + isDialBindingId(item, "selector")) + .filter((item, index, items) => items.indexOf(item) === index) + .slice(0, 30) : [] }; } @@ -341,6 +349,10 @@ function titleCase(value: string): 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; } @@ -407,15 +419,16 @@ function agentFeedback( return feedback("AGENT", "UNAVAILABLE", "NO ACTIVE AGENTS", 0, DIAL_ACCENTS.muted); } const context = finitePercent(agent.contextUsedPercent); + const status = item.detail ?? "unknown"; const detail = context == null - ? agent.status - : `${agent.status} • ${Math.round(context)}% context`; + ? status + : `${status} • ${Math.round(context)}% context`; return feedback( - `AGENT ${agent.id}`, - agent.title, + `AGENT ${agentDisplayNumber(agent.id)}`, + item.label, detail, context ?? 0, - agentAccent(agent.status) + agentAccent(status) ); } @@ -582,6 +595,7 @@ function feedback( function displayText(value: string, maximum: number): string { const uppercase = value.replace(/\s+/g, " ").trim().toUpperCase(); - if (uppercase.length <= maximum) return uppercase; - return `${uppercase.slice(0, maximum - 1).trimEnd()}…`; + const codePoints = Array.from(uppercase); + if (codePoints.length <= maximum) return uppercase; + return `${codePoints.slice(0, maximum - 1).join("").trimEnd()}…`; } diff --git a/test/dial-domain.test.ts b/test/dial-domain.test.ts index 267305e..5f1092b 100644 --- a/test/dial-domain.test.ts +++ b/test/dial-domain.test.ts @@ -23,6 +23,7 @@ import { type DialPreset, type DialRuntimeView } from "../src/dial-types.js"; +import { OFFICIAL_KEYCAP_IDS } from "../src/keycaps.js"; function withInheritedProperty( values: Record, inheritedKey: string @@ -54,7 +55,7 @@ const RUNTIME_VIEW: DialRuntimeView = { reasoningEffort: "high", agents: [ { - id: 1, + id: 0, identity: "rollout-alpha", threadKey: "thread-alpha", title: "Alpha task", @@ -62,7 +63,7 @@ const RUNTIME_VIEW: DialRuntimeView = { contextUsedPercent: 42 }, { - id: 4, + id: 3, identity: "rollout-delta", threadKey: "thread-delta", title: "Delta task", @@ -100,14 +101,14 @@ test("runtime selectors expose occupied agents, exact usage choices, and ordered id: "rollout-alpha", label: "Alpha task", detail: "thinking", - agentSlot: 1, + agentSlot: 0, threadKey: "thread-alpha" }, { id: "rollout-delta", label: "Delta task", detail: "idle", - agentSlot: 4, + agentSlot: 3, threadKey: "thread-delta" } ]); @@ -126,6 +127,25 @@ test("runtime selectors expose occupied agents, exact usage choices, and ordered ]); }); +test("action selector normalization removes duplicates before rotation", () => { + const settings = actionSelector(["keycap.FAST", "keycap.FAST", "keycap.APPR"]); + assert.deepEqual(settings.rotation, { + kind: "selector", + source: "actions", + wrap: true, + items: ["keycap.FAST", "keycap.APPR"] + }); + assert.equal( + reduceDialRotation( + settings, + { ...initialDialRuntimeState(), selectedId: "keycap.FAST" }, + RUNTIME_VIEW, + 1 + ).state.selectedId, + "keycap.APPR" + ); +}); + test("selector reconciliation preserves stable identity across reorder and handles disappearance", () => { const original = selectorItems(expandDialPreset("agents"), RUNTIME_VIEW); assert.deepEqual(reconcileSelector(initialDialRuntimeState(), original), { @@ -183,6 +203,43 @@ test("paired rotation emits one binding for every physical detent in both direct assert.equal(reduceDialRotation(settings, state, RUNTIME_VIEW, 1_001).bindings.length, 1_001); }); +test("rotation accepts the full physical event range and rejects impossible counts", () => { + const reasoning = expandDialPreset("reasoning"); + const state = initialDialRuntimeState(); + const clockwise = reduceDialRotation(reasoning, state, RUNTIME_VIEW, 4_096); + assert.equal(clockwise.bindings.length, 4_096); + assert.equal(clockwise.bindings[0], "reasoning.increase"); + assert.equal(clockwise.bindings.at(-1), "reasoning.increase"); + const counterClockwise = reduceDialRotation(reasoning, state, RUNTIME_VIEW, -4_096); + assert.equal(counterClockwise.bindings.length, 4_096); + assert.equal(counterClockwise.bindings[0], "reasoning.decrease"); + assert.equal(counterClockwise.bindings.at(-1), "reasoning.decrease"); + + for (const ticks of [ + 4_097, + -4_097, + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER + 1 + ]) { + const rejected = reduceDialRotation(reasoning, state, RUNTIME_VIEW, ticks); + assert.equal(rejected.state, state, String(ticks)); + assert.deepEqual(rejected.bindings, [], String(ticks)); + } + + const usage = expandDialPreset("usage"); + assert.equal( + reduceDialRotation(usage, state, RUNTIME_VIEW, 4_096).state.selectedId, + "five-hour" + ); + assert.equal( + reduceDialRotation(usage, state, RUNTIME_VIEW, -4_096).state.selectedId, + "weekly" + ); + const hugeSelector = reduceDialRotation(usage, state, RUNTIME_VIEW, Number.MAX_SAFE_INTEGER); + assert.equal(hugeSelector.state, state); + assert.deepEqual(hugeSelector.bindings, []); +}); + test("selector rotation previews without dispatch and wraps or clamps as configured", () => { const usage = expandDialPreset("usage"); const weekly = { @@ -296,6 +353,46 @@ test("agent and action feedback use reconciled selections and bounded live label assert.equal(bounded.value.length <= 24, true); }); +test("zero-based agents use one-based display numbers and nonblank text fallbacks", () => { + const blankAgentView: DialRuntimeView = { + ...RUNTIME_VIEW, + agents: [{ + id: 0, + identity: "blank-agent", + threadKey: "thread-blank", + title: " ", + status: "\n\t" + }] + }; + assert.deepEqual(selectorItems(expandDialPreset("agents"), blankAgentView), [{ + id: "blank-agent", + label: "Agent 1", + detail: "unknown", + agentSlot: 0, + threadKey: "thread-blank" + }]); + assert.deepEqual( + deriveDialFeedback(expandDialPreset("agents"), initialDialRuntimeState(), blankAgentView), + { + title: "AGENT 1", + value: "AGENT 1", + detail: "UNKNOWN", + indicator: 0, + accent: "#707B85" + } + ); +}); + +test("feedback truncation preserves complete Unicode code points before the ellipsis", () => { + const settings = normalizeDialSettings({ + ...expandDialPreset("custom"), + staticLabel: `${"a".repeat(22)}😀bc` + }); + const value = deriveDialFeedback(settings, initialDialRuntimeState(), RUNTIME_VIEW).value; + assert.equal(value, `${"A".repeat(22)}😀…`); + assert.equal(Array.from(value).length, 24); +}); + test("navigation and static feedback are explicit, while auto follows the rotation source", () => { assert.deepEqual( deriveDialFeedback(expandDialPreset("navigation"), initialDialRuntimeState(), RUNTIME_VIEW), @@ -659,10 +756,13 @@ test("normalization ignores inherited nested rotation properties", () => { }); test("selector normalization validates structure, filters bindings, and caps items", () => { - const requestedItems: string[] = Array.from({ length: 35 }, (_, index) => - index % 2 === 0 ? "micro.ACT06" : "keycap.FAST" - ); - requestedItems.splice(2, 0, "shell.rm", "selector.activate", "usage.rate-limit-reset"); + const requestedItems: string[] = [ + ...OFFICIAL_KEYCAP_IDS.map((id) => `keycap.${id}`), + ...MICRO_SLOTS.map((slot) => `micro.${slot}`), + "shell.rm", + "selector.activate", + "usage.rate-limit-reset" + ]; const normalized = normalizeDialSettings({ version: 1, preset: "actions", From 27937c3d2cc23d25259eeb3ba560014defb1cf46 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 14:00:07 -0400 Subject: [PATCH 008/137] feat: expose reasoning and refresh usage snapshots --- src/codex-micro-renderer-bridge.ts | 28 +++++++++++++- src/codex-relay-client.ts | 5 +++ src/codex-relay-server.ts | 4 +- src/controller.ts | 34 +++++++++++++++++ src/relay-protocol.ts | 11 +++++- src/types.ts | 2 + test/micro-bridge.test.ts | 22 +++++++++++ test/relay.test.ts | 60 ++++++++++++++++++++++++++---- 8 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index 36c209e..02b0ffc 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -214,6 +214,9 @@ const SNAPSHOT_EXPRESSION = `(async () => { })); let usage; + const forceRefreshKey = Symbol.for('codex-deck-force-rate-limit-refresh'); + const forceRefresh = globalThis[forceRefreshKey] === true; + if (forceRefresh) delete globalThis[forceRefreshKey]; for (const client of queryClients) { try { const query = client.getQueryCache().getAll().find((candidate) => @@ -223,7 +226,9 @@ const SNAPSHOT_EXPRESSION = `(async () => { 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) { + if (forceRefresh && query && typeof query.fetch === 'function') { + await Promise.resolve(query.fetch()); + } else 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 @@ -303,8 +308,17 @@ const SNAPSHOT_EXPRESSION = `(async () => { const activeThreadTitle = activeThreadElement ? (activeThreadElement.getAttribute('aria-label') ?? activeThreadElement.textContent ?? '').trim().slice(0, 240) || undefined : undefined; + const reasoningEffortValue = document.querySelector('[data-selected-reasoning-effort]') + ?.getAttribute('data-selected-reasoning-effort')?.trim(); + const reasoningEffort = reasoningEffortValue && reasoningEffortValue.length <= 64 + ? reasoningEffortValue + : undefined; - return { slots, activeThreadKey, activeThreadTitle, layout, agentSource, lightingAutoOff, theme, ...(usage ? { usage } : {}) }; + return { + slots, activeThreadKey, activeThreadTitle, layout, agentSource, lightingAutoOff, theme, + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(usage ? { usage } : {}) + }; })()`; export class CodexMicroRendererBridge { @@ -331,6 +345,16 @@ export class CodexMicroRendererBridge { } } + async requestUsageRefresh(): Promise { + await this.ensureConnected(); + await this.evaluate(`globalThis[Symbol.for('codex-deck-force-rate-limit-refresh')] = true`); + return this.refresh(); + } + + async refreshUsage(): Promise { + await this.requestUsageRefresh(); + } + 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(); diff --git a/src/codex-relay-client.ts b/src/codex-relay-client.ts index fe00269..811e240 100644 --- a/src/codex-relay-client.ts +++ b/src/codex-relay-client.ts @@ -31,6 +31,7 @@ export class CodexRelayClient { private host?: CodexHost; private snapshot?: HostSnapshot; private lastSnapshotReceivedAt = 0; + private capabilities = new Set(); private health: HostHealth = { state: "connecting", reason: "awaiting-snapshot", changedAt: Date.now() }; private readonly pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); @@ -48,6 +49,7 @@ export class CodexRelayClient { this.reconnect = undefined; this.socket?.close(1000, "client stopping"); this.socket = undefined; + this.capabilities.clear(); this.health = { state: "offline", reason: "relay-disconnected", changedAt: Date.now() }; this.rejectPending("Remote Codex relay disconnected."); } @@ -58,6 +60,7 @@ export class CodexRelayClient { return resolveRelayHealth(this.health, this.snapshot != null, this.lastSnapshotReceivedAt, now); } isConnected(): boolean { return this.socket?.readyState === WebSocket.OPEN && this.host != null; } + supportsCapability(capability: string): boolean { return this.capabilities.has(capability); } async send(command: RelayCommand): Promise { const socket = this.socket; @@ -99,6 +102,7 @@ export class CodexRelayClient { if (!message) return; if (message.type === "ready") { this.host = message.host; + this.capabilities = new Set(message.capabilities ?? []); 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") { @@ -129,6 +133,7 @@ export class CodexRelayClient { private disconnected(expected: WebSocket): void { if (this.socket !== expected) return; this.socket = undefined; + this.capabilities.clear(); 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..a3966f6 100644 --- a/src/codex-relay-server.ts +++ b/src/codex-relay-server.ts @@ -28,7 +28,8 @@ export type RelayServerConfig = { }; type RelayControl = Pick; + "refresh" | "sendAgent" | "sendAction" | "sendJoystick" | "sendEncoder" | "adjustReasoning" | "runKeycap" | + "refreshUsage" | "consumeRateLimitReset">; export class CodexRelayServer { private server?: WebSocketServer; @@ -353,6 +354,7 @@ async function executeRelayCommand(control: RelayControl, command: RelayCommand) 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 === "usage-refresh") return control.refreshUsage(); if (command.kind === "rate-limit-reset") return control.consumeRateLimitReset(); return control.runKeycap(command.keycapId as OfficialKeycapId); } diff --git a/src/controller.ts b/src/controller.ts index ae08aac..87f928b 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -137,6 +137,7 @@ export class DeckController { () => this.microBridge.adjustReasoning(direction)), runKeycap: (keycapId: OfficialKeycapId) => runAndInvalidate( () => this.microBridge.runKeycap(keycapId)), + refreshUsage: () => runAndInvalidate(() => this.microBridge.requestUsageRefresh().then(() => undefined)), consumeRateLimitReset: () => runAndInvalidate(() => this.microBridge.consumeRateLimitReset()) }; if (mobileRelayConfig) { @@ -328,6 +329,39 @@ export class DeckController { else await openCodexThread("new"); } + async refreshUsage(): Promise { + const source = this.accountUsageSource(); + if (source.hostId != null && source.hostId !== this.localHost?.hostId) { + if (!this.relayClient?.supportsCapability("usage-refresh")) { + throw new Error("Remote Codex host does not support usage refresh."); + } + await this.relayClient.send({ kind: "usage-refresh" }); + return; + } + + const host = this.localHost ?? await getOrCreateHostIdentity(); + try { + const snapshot = await this.microBridge.requestUsageRefresh(); + const observedAt = Date.now(); + this.localHost = host; + this.mobileRelayServer?.updateHost(host); + this.localMobileRelayServer?.updateHost(host); + this.localSnapshot = { host, snapshot, observedAt }; + this.localHealth = { state: "ready", changedAt: observedAt }; + this.lastError = ""; + } catch (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(); + } + private async refresh(): Promise { if (this.refreshInFlight) return this.refreshInFlight; const pending = this.refreshOnce(); diff --git a/src/relay-protocol.ts b/src/relay-protocol.ts index 6bab6a1..7e8ffa4 100644 --- a/src/relay-protocol.ts +++ b/src/relay-protocol.ts @@ -11,12 +11,13 @@ export type RelayCommand = | { kind: "joystick"; direction: MicroDirection; distance: 0 | 1 } | { kind: "encoder"; act: 0 | 1 } | { kind: "reasoning"; direction: ReasoningAdjustment } + | { kind: "usage-refresh" } | { kind: "rate-limit-reset" } | { kind: "keycap"; keycapId: OfficialKeycapId }; export type RelayAuthMessage = { type: "auth"; protocol: 1; token: string }; export const RELAY_CAPABILITIES = [ - "agent", "action", "joystick", "encoder", "reasoning", "keycap", "usage", "rate-limit-reset" + "agent", "action", "joystick", "encoder", "reasoning", "keycap", "usage", "usage-refresh", "rate-limit-reset" ] as const; export type RelayReadyMessage = { type: "ready"; @@ -236,7 +237,10 @@ 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 === "ready" && isHost(value.host) && + (value.capabilities == null || (Array.isArray(value.capabilities) && value.capabilities.every((item) => typeof item === "string")))) { + return value as RelayReadyMessage; + } if (value.type === "snapshot" && isHost(value.host) && Number.isFinite(value.observedAt) && isSnapshot(value.snapshot)) { return value as RelaySnapshotMessage; } @@ -257,6 +261,7 @@ export function parseRelayCommand(value: unknown): RelayCommand | null { 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 === "usage-refresh" && Object.keys(value).length === 1) 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; return null; @@ -268,6 +273,8 @@ function isSnapshot(value: unknown): value is MicroSnapshot { (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.reasoningEffort != null && + (typeof value.reasoningEffort !== "string" || value.reasoningEffort.trim().length === 0 || value.reasoningEffort.length > 64)) 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) => diff --git a/src/types.ts b/src/types.ts index 3d9ec29..ffbfdf3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,8 @@ export type UsageSnapshot = { export type MicroSnapshot = { slots: MicroAgentSlot[]; + /** Current reasoning effort selected in the active renderer composer. */ + reasoningEffort?: string; /** 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/test/micro-bridge.test.ts b/test/micro-bridge.test.ts index b272d59..7a16e71 100644 --- a/test/micro-bridge.test.ts +++ b/test/micro-bridge.test.ts @@ -48,6 +48,28 @@ test("renderer bridge uses native Micro events and discovers hashed modules at r assert.doesNotMatch(source, /D90_rd6W|SFcKxWqG|DJFcGyy5/); }); +test("renderer snapshots expose only the active composer's bounded reasoning effort", async () => { + const source = await readFile(new URL("../src/codex-micro-renderer-bridge.ts", import.meta.url), "utf8"); + assert.match(source, /querySelector\('\[data-selected-reasoning-effort\]'\)/); + assert.match(source, /getAttribute\('data-selected-reasoning-effort'\)/); + assert.match(source, /reasoningEffortValue[\s\S]*?trim\(\)/); + assert.match(source, /reasoningEffortValue\.length <= 64/); + assert.match(source, /reasoningEffort \? \{ reasoningEffort \} : \{\}/); +}); + +test("usage refresh distinguishes bounded background refreshes from a forced awaited fetch", async () => { + const source = await readFile(new URL("../src/codex-micro-renderer-bridge.ts", import.meta.url), "utf8"); + assert.match(source, /Symbol\.for\('codex-deck-force-rate-limit-refresh'\)/); + assert.match(source, /delete globalThis\[forceRefreshKey\]/); + assert.match(source, /if \(forceRefresh[\s\S]*await Promise\.resolve\(query\.fetch\(\)\)/); + assert.match(source, /now - dataUpdatedAt >= 15000/); + assert.match(source, /Promise\.resolve\(query\.fetch\(\)\)\.catch\(\(\) => \{\}\)/); + assert.match(source, /async requestUsageRefresh\(\): Promise/); + assert.match(source, /requestUsageRefresh[\s\S]*await this\.ensureConnected\(\)/); + assert.match(source, /requestUsageRefresh[\s\S]*this\.evaluate[\s\S]*codex-deck-force-rate-limit-refresh/); + assert.match(source, /requestUsageRefresh[\s\S]*return this\.refresh\(\)/); +}); + test("renderer bridge prefers the main index document over macOS avatar surfaces", () => { const target = selectCodexMainTarget([ { type: "page", url: "app://-/index.html?initialRoute=%2Favatar-overlay", webSocketDebuggerUrl: "ws://route" }, diff --git a/test/relay.test.ts b/test/relay.test.ts index f7bb6bc..e436551 100644 --- a/test/relay.test.ts +++ b/test/relay.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { X509Certificate } from "node:crypto"; import { createServer } from "node:net"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -112,6 +112,8 @@ test("relay command parser permits only the narrow native command surface", () = assert.deepEqual(parseRelayCommand({ kind: "agent", slot: 5, threadKey, act: 1 }), { kind: "agent", slot: 5, threadKey, act: 1 }); assert.deepEqual(parseRelayCommand({ kind: "reasoning", direction: "increase" }), { kind: "reasoning", direction: "increase" }); assert.deepEqual(parseRelayCommand({ kind: "rate-limit-reset" }), { kind: "rate-limit-reset" }); + assert.deepEqual(parseRelayCommand({ kind: "usage-refresh" }), { kind: "usage-refresh" }); + assert.equal(parseRelayCommand({ kind: "usage-refresh", expression: "process.exit()" }), null); assert.equal(parseRelayCommand({ kind: "agent", slot: 6, threadKey, act: 1 }), null); assert.equal(parseRelayCommand({ kind: "evaluate", expression: "process.exit()" }), null); assert.equal(parseRelayCommand({ kind: "keycap", keycapId: "NOT_REAL" }), null); @@ -121,6 +123,20 @@ test("relay command parser permits only the narrow native command surface", () = assert.equal(parseRelayCommand({ kind: "agent", slot: 1, threadKey: "local:../../secret", act: 1 }), null); }); +test("relay snapshots accept an optional bounded reasoning effort", async () => { + const { parseRelayServerMessage } = await import("../src/relay-protocol.js"); + const message = { type: "snapshot", protocol: 1, host, observedAt: 1, snapshot: structuredClone(snapshot) }; + assert.notEqual(parseRelayServerMessage(message), null, "older peers may omit the optional field"); + message.snapshot.reasoningEffort = "high"; + assert.notEqual(parseRelayServerMessage(message), null); + message.snapshot.reasoningEffort = ""; + assert.equal(parseRelayServerMessage(message), null); + message.snapshot.reasoningEffort = " "; + assert.equal(parseRelayServerMessage(message), null); + message.snapshot.reasoningEffort = "x".repeat(65); + assert.equal(parseRelayServerMessage(message), null); +}); + test("relay snapshot parser bounds and validates host session catalogs", async () => { const { parseRelayServerMessage } = await import("../src/relay-protocol.js"); const valid = { type: "snapshot", protocol: 1, host, observedAt: 1, snapshot: structuredClone(snapshot) }; @@ -771,7 +787,8 @@ test("authenticated relay publishes snapshots and dispatches typed commands", as refresh: async () => snapshot, sendAgent: async (slot: number, act: 0 | 1) => { calls.push(["agent", slot, act]); }, sendAction: async () => {}, sendJoystick: async () => {}, sendEncoder: async () => {}, - adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {} + adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {}, + refreshUsage: async () => { calls.push(["usage-refresh"]); } }; const server = new CodexRelayServer( { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, () => {} @@ -785,7 +802,7 @@ test("authenticated relay publishes snapshots and dispatches typed commands", as assert.equal(first.type, "ready"); assert.equal(first.bridge, "native-codex-micro"); assert.deepEqual(first.capabilities, [ - "agent", "action", "joystick", "encoder", "reasoning", "keycap", "usage", "rate-limit-reset" + "agent", "action", "joystick", "encoder", "reasoning", "keycap", "usage", "usage-refresh", "rate-limit-reset" ]); const second = await messages.next(); assert.equal(second.type, "snapshot"); @@ -797,6 +814,17 @@ test("authenticated relay publishes snapshots and dispatches typed commands", as assert.deepEqual(calls, [["agent", 2, 1]]); assert.equal(result.type, "result"); assert.equal(result.ok, true); + socket.send(JSON.stringify({ + type: "command", protocol: RELAY_PROTOCOL_VERSION, requestId: "request-2", + command: { kind: "usage-refresh" } + })); + let refreshResult = await messages.next(); + while (refreshResult.type !== "result" || refreshResult.requestId !== "request-2") { + refreshResult = await messages.next(); + } + assert.deepEqual(calls, [["agent", 2, 1], ["usage-refresh"]]); + assert.equal(refreshResult.type, "result"); + assert.equal(refreshResult.ok, true); socket.close(); await server.close(); }); @@ -806,7 +834,8 @@ test("running relay publishes refreshed Codex metadata without changing host ide const control = { refresh: async () => snapshot, sendAgent: async () => {}, sendAction: async () => {}, sendJoystick: async () => {}, - sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {} + sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {}, + refreshUsage: async () => {} }; const server = new CodexRelayServer( { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, @@ -838,7 +867,8 @@ test("relay rejects a client with the wrong token before publishing state", asyn const control = { refresh: async () => { refreshes += 1; return snapshot; }, sendAgent: async () => {}, sendAction: async () => {}, sendJoystick: async () => {}, - sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {} + sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {}, + refreshUsage: async () => {} }; const server = new CodexRelayServer( { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, () => {} @@ -859,7 +889,8 @@ test("authenticated relay survives an unavailable Codex snapshot", async () => { const control = { refresh: async (): Promise => { throw new Error("bridge offline"); }, sendAgent: async () => {}, sendAction: async () => {}, sendJoystick: async () => {}, - sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {} + sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {}, + refreshUsage: async () => {} }; const server = new CodexRelayServer( { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, @@ -886,7 +917,8 @@ test("relay client preserves last-known tasks but marks their host offline after const control = { refresh: async () => snapshot, sendAgent: async () => {}, sendAction: async () => {}, sendJoystick: async () => {}, - sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {} + sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, consumeRateLimitReset: async () => {}, + refreshUsage: async () => {} }; const server = new CodexRelayServer( { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, () => {} @@ -899,6 +931,8 @@ test("relay client preserves last-known tasks but marks their host offline after ); client.start(); await waitUntil(() => client.currentHealth().state === "ready"); + assert.equal(client.supportsCapability("usage-refresh"), true); + assert.equal(client.supportsCapability("arbitrary-evaluate"), false); assert.equal(deliveredSnapshots.length, 1); const lastKnown = client.currentSnapshot(); assert.equal(lastKnown?.snapshot.slots[0]?.title, "Task 1"); @@ -907,9 +941,21 @@ test("relay client preserves last-known tasks but marks their host offline after assert.equal(deliveredSnapshots.length, 1, "health-only transitions must not call the snapshot callback"); assert.equal(client.currentSnapshot(), lastKnown); assert.equal(client.isConnected(), false); + assert.equal(client.supportsCapability("usage-refresh"), false); client.close(); }); +test("controller refreshes account usage on its source host instead of the selected function host", async () => { + const source = await readFile(new URL("../src/controller.ts", import.meta.url), "utf8"); + assert.match(source, /async refreshUsage\(\): Promise/); + assert.match(source, /refreshUsage[\s\S]*const source = this\.accountUsageSource\(\)/); + assert.match(source, /refreshUsage[\s\S]*this\.microBridge\.requestUsageRefresh\(\)/); + assert.match(source, /refreshUsage[\s\S]*supportsCapability\("usage-refresh"\)/); + assert.match(source, /refreshUsage[\s\S]*\{ kind: "usage-refresh" \}/); + assert.match(source, /Remote Codex host does not support usage refresh\./); + assert.match(source, /refreshUsage: \(\) => runAndInvalidate\(\(\) => this\.microBridge\.requestUsageRefresh\(\)\.then\(\(\) => undefined\)\)/); +}); + async function freePort(): Promise { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); From 0cf0dc91ed50f9752ea7517ffa9fa68967f6d6fb Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 14:17:09 -0400 Subject: [PATCH 009/137] fix: harden usage refresh ordering --- src/codex-micro-renderer-bridge.ts | 110 +++++++++++++++------- src/codex-relay-client.ts | 23 ++++- src/codex-relay-server.ts | 32 ++++--- src/controller.ts | 2 +- test/micro-bridge.test.ts | 89 ++++++++++++++---- test/relay.test.ts | 143 ++++++++++++++++++++++++++++- 6 files changed, 332 insertions(+), 67 deletions(-) diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index 02b0ffc..7efd469 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -68,7 +68,59 @@ export const REASONING_ENCODER_KEYS: Record { +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; +} + +type ReasoningEffortElement = { + isConnected?: boolean; + getClientRects?: () => { length: number }; + getAttribute: (name: string) => string | null; +}; + +export function readActiveReasoningEffort( + elements: Iterable, + isVisible = (element: ReasoningEffortElement): 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"; + } +): string | undefined { + for (const element of elements) { + if (!isVisible(element)) continue; + const value = element.getAttribute("data-selected-reasoning-effort")?.trim(); + if (value && value.length <= 64) return value; + } + return undefined; +} + +const SNAPSHOT_EXPRESSION = (forceUsageRefresh: boolean): string => `(async () => { + const forceUsageRefresh = ${forceUsageRefresh}; + const readUsageQueryData = (${readUsageQueryData.toString()}); + const readActiveReasoningEffort = (${readActiveReasoningEffort.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,28 +266,14 @@ const SNAPSHOT_EXPRESSION = `(async () => { })); let usage; - const forceRefreshKey = Symbol.for('codex-deck-force-rate-limit-refresh'); - const forceRefresh = globalThis[forceRefreshKey] === true; - if (forceRefresh) delete globalThis[forceRefreshKey]; + 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 (forceRefresh && query && typeof query.fetch === 'function') { - await Promise.resolve(query.fetch()); - } else 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; + if (forceUsageRefresh && query && typeof query.fetch === 'function') forcedUsageQueryFound = true; + const data = await readUsageQueryData(query, forceUsageRefresh); const rateLimit = data?.rate_limit; if (!rateLimit || typeof rateLimit !== 'object') continue; const normalizeWindow = (window, role) => { @@ -272,8 +310,11 @@ const SNAPSHOT_EXPRESSION = `(async () => { resetCreditsApplicable: Number.isFinite(applicable) ? Math.max(0, Math.floor(applicable)) : null }; break; - } catch {} + } catch (error) { + if (forceUsageRefresh) throw error; + } } + if (forceUsageRefresh && !forcedUsageQueryFound) throw new Error('Codex usage query is unavailable.'); const html = document.documentElement; const body = document.body; @@ -308,11 +349,9 @@ const SNAPSHOT_EXPRESSION = `(async () => { const activeThreadTitle = activeThreadElement ? (activeThreadElement.getAttribute('aria-label') ?? activeThreadElement.textContent ?? '').trim().slice(0, 240) || undefined : undefined; - const reasoningEffortValue = document.querySelector('[data-selected-reasoning-effort]') - ?.getAttribute('data-selected-reasoning-effort')?.trim(); - const reasoningEffort = reasoningEffortValue && reasoningEffortValue.length <= 64 - ? reasoningEffortValue - : undefined; + const reasoningEffort = readActiveReasoningEffort(document.querySelectorAll( + 'button[data-selected-reasoning-effort], [role="button"][data-selected-reasoning-effort], [aria-haspopup][data-selected-reasoning-effort]' + )); return { slots, activeThreadKey, activeThreadTitle, layout, agentSource, lightingAutoOff, theme, @@ -334,11 +373,7 @@ export class CodexMicroRendererBridge { 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; @@ -346,15 +381,26 @@ export class CodexMicroRendererBridge { } async requestUsageRefresh(): Promise { - await this.ensureConnected(); - await this.evaluate(`globalThis[Symbol.for('codex-deck-force-rate-limit-refresh')] = true`); - return this.refresh(); + 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)); + 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(); diff --git a/src/codex-relay-client.ts b/src/codex-relay-client.ts index 811e240..7bcd0a7 100644 --- a/src/codex-relay-client.ts +++ b/src/codex-relay-client.ts @@ -32,6 +32,9 @@ export class CodexRelayClient { private snapshot?: HostSnapshot; private lastSnapshotReceivedAt = 0; private capabilities = new Set(); + private connectionGeneration = 0; + private readyGeneration = 0; + private snapshotGeneration = 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 }>(); @@ -50,6 +53,7 @@ export class CodexRelayClient { this.socket?.close(1000, "client stopping"); this.socket = undefined; this.capabilities.clear(); + this.readyGeneration = 0; this.health = { state: "offline", reason: "relay-disconnected", changedAt: Date.now() }; this.rejectPending("Remote Codex relay disconnected."); } @@ -61,6 +65,15 @@ export class CodexRelayClient { } isConnected(): boolean { return this.socket?.readyState === WebSocket.OPEN && this.host != null; } supportsCapability(capability: string): boolean { return this.capabilities.has(capability); } + supportsCapabilityForSnapshot(capability: string, hostId: string): boolean { + return this.socket?.readyState === WebSocket.OPEN && + this.currentHealth().state === "ready" && + this.readyGeneration === this.connectionGeneration && + this.snapshotGeneration === this.connectionGeneration && + this.host?.hostId === hostId && + this.snapshot?.host.hostId === hostId && + this.capabilities.has(capability); + } async send(command: RelayCommand): Promise { const socket = this.socket; @@ -81,10 +94,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) { @@ -94,7 +108,8 @@ 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; } @@ -103,9 +118,11 @@ export class CodexRelayClient { if (message.type === "ready") { 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 || this.host?.hostId !== message.host.hostId) return; const receivedAt = Date.now(); this.host = message.host; this.snapshot = normalizeHostSnapshotAtReceipt( @@ -113,6 +130,7 @@ export class CodexRelayClient { receivedAt ); this.lastSnapshotReceivedAt = receivedAt; + this.snapshotGeneration = generation; this.health = { state: "ready", changedAt: receivedAt }; this.onSnapshot(this.snapshot); } else if (message.type === "health") { @@ -134,6 +152,7 @@ export class CodexRelayClient { if (this.socket !== expected) return; this.socket = undefined; this.capabilities.clear(); + this.readyGeneration = 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 a3966f6..bd5ba11 100644 --- a/src/codex-relay-server.ts +++ b/src/codex-relay-server.ts @@ -205,9 +205,10 @@ export class CodexRelayServer { this.log(`Relay command ${commandLabel} received.`); try { await executeRelayCommand(this.control, command); + if (command.kind === "usage-refresh") await this.publishSnapshot(undefined, true); this.sendResult(socket, message.requestId, true); this.log(`Relay command ${commandLabel} completed in ${Date.now() - startedAt} ms.`); - await this.publishSnapshot(); + if (command.kind !== "usage-refresh") 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}`); @@ -261,8 +262,8 @@ 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); if (this.consecutiveSnapshotFailures > 0) { this.log(`Relay snapshot recovered after ${this.consecutiveSnapshotFailures} transient failure${this.consecutiveSnapshotFailures === 1 ? "" : "s"}.`); } @@ -277,15 +278,22 @@ export class CodexRelayServer { } } - 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; } diff --git a/src/controller.ts b/src/controller.ts index 87f928b..59b740d 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -332,7 +332,7 @@ export class DeckController { async refreshUsage(): Promise { const source = this.accountUsageSource(); if (source.hostId != null && source.hostId !== this.localHost?.hostId) { - if (!this.relayClient?.supportsCapability("usage-refresh")) { + if (!this.relayClient?.supportsCapabilityForSnapshot("usage-refresh", source.hostId)) { throw new Error("Remote Codex host does not support usage refresh."); } await this.relayClient.send({ kind: "usage-refresh" }); diff --git a/test/micro-bridge.test.ts b/test/micro-bridge.test.ts index 7a16e71..489c5af 100644 --- a/test/micro-bridge.test.ts +++ b/test/micro-bridge.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { REASONING_ENCODER_KEYS, resolveAgentDispatch, retainEvaluationPromise, selectCodexMainTarget } from "../src/codex-micro-renderer-bridge.js"; +import * as microBridgeModule from "../src/codex-micro-renderer-bridge.js"; import { ADDITIONAL_KEYCAPS, OFFICIAL_KEYCAP_IDS } from "../src/keycaps.js"; import { visualStatusFromMicro } from "../src/status.js"; import type { MicroSnapshot } from "../src/types.js"; @@ -48,26 +49,59 @@ test("renderer bridge uses native Micro events and discovers hashed modules at r assert.doesNotMatch(source, /D90_rd6W|SFcKxWqG|DJFcGyy5/); }); -test("renderer snapshots expose only the active composer's bounded reasoning effort", async () => { - const source = await readFile(new URL("../src/codex-micro-renderer-bridge.ts", import.meta.url), "utf8"); - assert.match(source, /querySelector\('\[data-selected-reasoning-effort\]'\)/); - assert.match(source, /getAttribute\('data-selected-reasoning-effort'\)/); - assert.match(source, /reasoningEffortValue[\s\S]*?trim\(\)/); - assert.match(source, /reasoningEffortValue\.length <= 64/); - assert.match(source, /reasoningEffort \? \{ reasoningEffort \} : \{\}/); +test("renderer snapshots choose the visible reasoning composer trigger", () => { + const readActiveReasoningEffort = Reflect.get(microBridgeModule, "readActiveReasoningEffort") as unknown; + assert.equal(typeof readActiveReasoningEffort, "function"); + const read = readActiveReasoningEffort as ( + elements: Array<{ getAttribute: (name: string) => string | null; visible: boolean }>, + isVisible: (element: { visible: boolean }) => boolean + ) => string | undefined; + const elements = [ + { getAttribute: () => "low", visible: false }, + { getAttribute: () => " high ", visible: true } + ]; + assert.equal(read(elements, (element) => element.visible), "high"); }); -test("usage refresh distinguishes bounded background refreshes from a forced awaited fetch", async () => { - const source = await readFile(new URL("../src/codex-micro-renderer-bridge.ts", import.meta.url), "utf8"); - assert.match(source, /Symbol\.for\('codex-deck-force-rate-limit-refresh'\)/); - assert.match(source, /delete globalThis\[forceRefreshKey\]/); - assert.match(source, /if \(forceRefresh[\s\S]*await Promise\.resolve\(query\.fetch\(\)\)/); - assert.match(source, /now - dataUpdatedAt >= 15000/); - assert.match(source, /Promise\.resolve\(query\.fetch\(\)\)\.catch\(\(\) => \{\}\)/); - assert.match(source, /async requestUsageRefresh\(\): Promise/); - assert.match(source, /requestUsageRefresh[\s\S]*await this\.ensureConnected\(\)/); - assert.match(source, /requestUsageRefresh[\s\S]*this\.evaluate[\s\S]*codex-deck-force-rate-limit-refresh/); - assert.match(source, /requestUsageRefresh[\s\S]*return this\.refresh\(\)/); +test("forced and normal bridge snapshots carry independent lexical refresh modes", async () => { + const bridge = new microBridgeModule.CodexMicroRendererBridge(() => {}); + const expressions: string[] = []; + const nativeSnapshot = snapshotFixture(); + const testBridge = bridge as unknown as { + ensureConnected: () => Promise; + evaluate: (expression: string) => Promise; + sessionOwnership: { annotate: (value: MicroSnapshot) => Promise }; + }; + testBridge.ensureConnected = async () => {}; + testBridge.evaluate = async (expression: string): Promise => { + expressions.push(expression); + return structuredClone(nativeSnapshot) as T; + }; + testBridge.sessionOwnership = { annotate: async (value) => value }; + + await Promise.all([bridge.requestUsageRefresh(), bridge.refresh()]); + + assert.equal(expressions.length, 2, "forced refresh must not require a separate flag evaluation"); + assert.match(expressions[0]!, /const forceUsageRefresh = true/); + assert.match(expressions[1]!, /const forceUsageRefresh = false/); + assert.equal(expressions.some((expression) => expression.includes("codex-deck-force-rate-limit-refresh")), false); +}); + +test("forced usage query failures reject while normal background refresh remains best effort", async () => { + const readUsageQueryData = Reflect.get(microBridgeModule, "readUsageQueryData") as unknown; + assert.equal(typeof readUsageQueryData, "function"); + const read = readUsageQueryData as ( + query: { state: { data: unknown; dataUpdatedAt: number }; fetch: () => Promise }, + forceUsageRefresh: boolean, + now?: number, + refreshState?: Record + ) => Promise; + const failure = new Error("usage fetch failed"); + const forced = { state: { data: "stale", dataUpdatedAt: 1 }, fetch: async () => { throw failure; } }; + await assert.rejects(read(forced, true, 20_000, {}), failure); + + const normal = { state: { data: "last-known", dataUpdatedAt: 1 }, fetch: async () => { throw failure; } }; + assert.equal(await read(normal, false, 20_000, {}), "last-known"); }); test("renderer bridge prefers the main index document over macOS avatar surfaces", () => { @@ -197,3 +231,22 @@ test("assigned titleless threads use a new-chat label instead of Not assigned", assert.match(source, /agent\?\.threadKey\s*&&\s*health\.state\s*===\s*"ready"\s*\?\s*"New chat"/); assert.match(source, /:\s*"Not assigned"/); }); + +function snapshotFixture(): MicroSnapshot { + return { + slots: Array.from({ length: 6 }, (_, id) => ({ + id, threadKey: null, title: null, status: "idle", selected: false + })), + layout: { + version: 1, + slots: { + ACT06: { keycapId: "FAST" }, ACT07: { keycapId: "APPR" }, ACT08: { keycapId: "REJ" }, + ACT09: { keycapId: "SPLIT" }, ACT10_ACT11: { keycapId: "CODEX" }, ACT12: { keycapId: "CODEX" } + }, + analogStick: { up: {}, right: {}, down: {}, left: {} } + }, + agentSource: "recent", + lightingAutoOff: "3-minutes", + theme: "dark" + }; +} diff --git a/test/relay.test.ts b/test/relay.test.ts index e436551..dc42f92 100644 --- a/test/relay.test.ts +++ b/test/relay.test.ts @@ -5,7 +5,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import WebSocket from "ws"; +import WebSocket, { WebSocketServer } from "ws"; import { generate } from "selfsigned"; import { CodexRelayClient, RELAY_SNAPSHOT_STALE_MS, resolveRelayHealth } from "../src/codex-relay-client.js"; import { isAllowedRelayHost, isPrivateLanHost, privateLanAddresses } from "../src/relay-network.js"; @@ -829,6 +829,62 @@ test("authenticated relay publishes snapshots and dispatches typed commands", as await server.close(); }); +test("usage refresh publishes a new post-command snapshot before acknowledging success", async (t) => { + const port = await freePort(); + let refreshCall = 0; + let usageRefreshes = 0; + let releasePreCommand!: (value: MicroSnapshot) => void; + const preCommand = new Promise((resolve) => { releasePreCommand = resolve; }); + const control = { + refresh: async () => { + refreshCall += 1; + if (refreshCall === 2) return preCommand; + return { ...structuredClone(snapshot), reasoningEffort: `snapshot-${refreshCall}` }; + }, + sendAgent: async () => {}, sendAction: async () => {}, sendJoystick: async () => {}, + sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {}, + consumeRateLimitReset: async () => {}, refreshUsage: async () => { usageRefreshes += 1; } + }; + const server = new CodexRelayServer( + { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, () => {} + ); + await server.start(); + const commandSocket = new WebSocket(`ws://127.0.0.1:${port}`); + const commandMessages = messageQueue(commandSocket); + await onceOpen(commandSocket); + commandSocket.send(JSON.stringify({ type: "auth", protocol: 1, token: "t".repeat(32) })); + assert.equal((await commandMessages.next()).type, "ready"); + assert.equal((await commandMessages.next()).type, "snapshot"); + + const blockingSocket = new WebSocket(`ws://127.0.0.1:${port}`); + t.after(async () => { + commandSocket.close(); + blockingSocket.close(); + await server.close(); + }); + const blockingMessages = messageQueue(blockingSocket); + await onceOpen(blockingSocket); + blockingSocket.send(JSON.stringify({ type: "auth", protocol: 1, token: "t".repeat(32) })); + assert.equal((await blockingMessages.next()).type, "ready"); + await waitUntil(() => refreshCall === 2); + + commandSocket.send(JSON.stringify({ + type: "command", protocol: 1, requestId: "fresh-usage", + command: { kind: "usage-refresh" } + })); + await waitUntil(() => usageRefreshes === 1); + releasePreCommand({ ...structuredClone(snapshot), reasoningEffort: "pre-command" }); + + const fresh = await commandMessages.next(); + const result = await commandMessages.next(); + assert.equal(refreshCall, 3, "post-command publication must force a new snapshot"); + assert.equal(fresh.type, "snapshot"); + assert.equal((fresh.snapshot as MicroSnapshot).reasoningEffort, "snapshot-3"); + assert.equal(result.type, "result"); + assert.equal(result.requestId, "fresh-usage"); + assert.equal(result.ok, true); +}); + test("running relay publishes refreshed Codex metadata without changing host identity", async () => { const port = await freePort(); const control = { @@ -945,17 +1001,100 @@ test("relay client preserves last-known tasks but marks their host offline after client.close(); }); +test("relay capabilities are valid only for a snapshot from the current connection generation", async (t) => { + const port = await freePort(); + const relay = new WebSocketServer({ host: "127.0.0.1", port }); + let connectionCount = 0; + let firstSocket: WebSocket | undefined; + let secondSocket: WebSocket | undefined; + relay.on("connection", (socket) => { + connectionCount += 1; + const connection = connectionCount; + if (connection === 1) firstSocket = socket; + else secondSocket = socket; + socket.once("message", () => { + socket.send(JSON.stringify({ + type: "ready", protocol: 1, host, capabilities: ["usage-refresh"], bridge: "native-codex-micro" + })); + if (connection === 1) socket.send(JSON.stringify({ + type: "snapshot", protocol: 1, host, observedAt: Date.now(), snapshot + })); + }); + }); + const client = new CodexRelayClient( + { enabled: true, url: `ws://127.0.0.1:${port}`, token: "t".repeat(32) }, () => {}, () => {} + ); + t.after(async () => { + client.close(); + for (const socket of relay.clients) socket.terminate(); + await new Promise((resolve) => relay.close(() => resolve())); + }); + client.start(); + await waitUntil(() => client.currentHealth().state === "ready"); + const generationGate = client as CodexRelayClient & { + supportsCapabilityForSnapshot(capability: string, hostId: string): boolean; + }; + assert.equal(generationGate.supportsCapabilityForSnapshot("usage-refresh", host.hostId), true); + + firstSocket!.close(); + await waitUntil(() => connectionCount === 2, 4_000); + await waitUntil(() => client.currentHealth().state === "degraded"); + assert.equal(client.currentSnapshot()?.host.hostId, host.hostId, "last-known snapshot stays available for display"); + assert.equal(generationGate.supportsCapabilityForSnapshot("usage-refresh", host.hostId), false); + + secondSocket!.send(JSON.stringify({ + type: "snapshot", protocol: 1, host, observedAt: Date.now(), snapshot + })); + await waitUntil(() => generationGate.supportsCapabilityForSnapshot("usage-refresh", host.hostId)); + assert.equal(generationGate.supportsCapabilityForSnapshot("usage-refresh", host.hostId), true); + assert.equal(generationGate.supportsCapabilityForSnapshot("usage-refresh", "wrong-host"), false); +}); + test("controller refreshes account usage on its source host instead of the selected function host", async () => { const source = await readFile(new URL("../src/controller.ts", import.meta.url), "utf8"); assert.match(source, /async refreshUsage\(\): Promise/); assert.match(source, /refreshUsage[\s\S]*const source = this\.accountUsageSource\(\)/); assert.match(source, /refreshUsage[\s\S]*this\.microBridge\.requestUsageRefresh\(\)/); - assert.match(source, /refreshUsage[\s\S]*supportsCapability\("usage-refresh"\)/); + assert.match(source, /refreshUsage[\s\S]*supportsCapabilityForSnapshot\("usage-refresh", source\.hostId\)/); assert.match(source, /refreshUsage[\s\S]*\{ kind: "usage-refresh" \}/); assert.match(source, /Remote Codex host does not support usage refresh\./); assert.match(source, /refreshUsage: \(\) => runAndInvalidate\(\(\) => this\.microBridge\.requestUsageRefresh\(\)\.then\(\(\) => undefined\)\)/); }); +test("controller preserves last-known usage and degrades health when forced refresh rejects", async () => { + const { DeckController } = await import("../src/controller.js"); + const controller = new DeckController(); + const knownSnapshot: HostSnapshot = { + host, + observedAt: 1_000, + snapshot: { + ...structuredClone(snapshot), + usage: { + windows: [], observedAt: 1_000, + resetCreditsAvailable: null, resetCreditsApplicable: null + } + } + }; + const state = controller as unknown as { + microBridge: { requestUsageRefresh: () => Promise }; + localHost: CodexHost; + localSnapshot: HostSnapshot; + localHealth: { state: string; changedAt: number; reason?: string }; + refreshDisplay: () => Promise; + }; + state.microBridge = { requestUsageRefresh: async () => { throw new Error("usage fetch failed"); } }; + state.localHost = host; + state.localSnapshot = knownSnapshot; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.refreshDisplay = async () => {}; + + await assert.rejects(controller.refreshUsage(), /usage fetch failed/); + assert.equal(state.localSnapshot, knownSnapshot); + assert.equal(state.localSnapshot.snapshot.usage?.observedAt, 1_000); + assert.equal(state.localHealth.state, "degraded"); + assert.equal(state.localHealth.reason, "local-bridge-unavailable"); +}); + async function freePort(): Promise { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); From 7d3a3c653eb0f0da7c9f02ebe4c6bf705e7f8c68 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 14:30:36 -0400 Subject: [PATCH 010/137] fix: require fresh usage refresh snapshots --- src/codex-micro-renderer-bridge.ts | 120 ++++++++++++++++++++--------- src/controller.ts | 38 ++++++--- test/micro-bridge.test.ts | 78 ++++++++++++++++++- test/relay.test.ts | 48 +++++++++++- 4 files changed, 235 insertions(+), 49 deletions(-) diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index 7efd469..9a71585 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -7,7 +7,9 @@ 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 type { + MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, UsageSnapshot, UsageWindow +} from "./types.js"; type DebugTarget = { type: string; @@ -95,6 +97,80 @@ export async function readUsageQueryData( 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 = 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: UsageWindow["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(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 available = Number(creditRecord.available_count); + const applicable = Number(creditRecord.applicable_available_count); + return { + windows, + observedAt: Number.isFinite(dataUpdatedAt) && Number(dataUpdatedAt) > 0 ? Number(dataUpdatedAt) : now, + resetCreditsAvailable: Number.isFinite(available) ? Math.max(0, Math.floor(available)) : null, + resetCreditsApplicable: Number.isFinite(applicable) ? Math.max(0, Math.floor(applicable)) : null + }; +} + +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 ReasoningEffortElement = { isConnected?: boolean; getClientRects?: () => { length: number }; @@ -120,6 +196,7 @@ export function readActiveReasoningEffort( const SNAPSHOT_EXPRESSION = (forceUsageRefresh: boolean): string => `(async () => { const forceUsageRefresh = ${forceUsageRefresh}; const readUsageQueryData = (${readUsageQueryData.toString()}); + const normalizeRendererUsage = (${normalizeRendererUsage.toString()}); const readActiveReasoningEffort = (${readActiveReasoningEffort.toString()}); const urls = [...new Set([ ...[...document.querySelectorAll('link[href], script[src]')].map((element) => element.href || element.src), @@ -274,47 +351,15 @@ const SNAPSHOT_EXPRESSION = (forceUsageRefresh: boolean): string => `(async () = ); if (forceUsageRefresh && query && typeof query.fetch === 'function') forcedUsageQueryFound = true; const data = await readUsageQueryData(query, forceUsageRefresh); - 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 - }; + usage = normalizeRendererUsage(data, query?.state?.dataUpdatedAt); + if (!usage) continue; break; } 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; @@ -396,6 +441,9 @@ export class CodexMicroRendererBridge { 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; diff --git a/src/controller.ts b/src/controller.ts index 59b740d..eeb1ee1 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -66,6 +66,7 @@ export class DeckController { private poll?: NodeJS.Timeout; private animation?: NodeJS.Timeout; private refreshInFlight?: Promise; + private localSnapshotGeneration = 0; private stopped = false; private animationFrame = 0; private lastError = ""; @@ -108,10 +109,7 @@ export class DeckController { 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. + // The relay server publishes a fresh snapshot after the command. mobileSnapshotDirty = true; }; const mobileControl = { @@ -137,7 +135,10 @@ export class DeckController { () => this.microBridge.adjustReasoning(direction)), runKeycap: (keycapId: OfficialKeycapId) => runAndInvalidate( () => this.microBridge.runKeycap(keycapId)), - refreshUsage: () => runAndInvalidate(() => this.microBridge.requestUsageRefresh().then(() => undefined)), + refreshUsage: async () => { + await this.refreshLocalUsage(); + mobileSnapshotDirty = false; + }, consumeRateLimitReset: () => runAndInvalidate(() => this.microBridge.consumeRateLimitReset()) }; if (mobileRelayConfig) { @@ -339,9 +340,17 @@ export class DeckController { return; } - const host = this.localHost ?? await getOrCreateHostIdentity(); + await this.refreshLocalUsage(); + } + + private async refreshLocalUsage(): Promise { + const generation = ++this.localSnapshotGeneration; try { + const host = this.localHost ?? await getOrCreateHostIdentity(); const 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); @@ -349,7 +358,10 @@ export class DeckController { this.localSnapshot = { host, snapshot, observedAt }; this.localHealth = { state: "ready", changedAt: observedAt }; this.lastError = ""; + await this.refreshDisplay(); + return snapshot; } 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) { @@ -359,7 +371,6 @@ export class DeckController { await this.refreshDisplay(); throw error; } - await this.refreshDisplay(); } private async refresh(): Promise { @@ -371,15 +382,20 @@ 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.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) { diff --git a/test/micro-bridge.test.ts b/test/micro-bridge.test.ts index 489c5af..41154f5 100644 --- a/test/micro-bridge.test.ts +++ b/test/micro-bridge.test.ts @@ -66,7 +66,16 @@ test("renderer snapshots choose the visible reasoning composer trigger", () => { test("forced and normal bridge snapshots carry independent lexical refresh modes", async () => { const bridge = new microBridgeModule.CodexMicroRendererBridge(() => {}); const expressions: string[] = []; - const nativeSnapshot = snapshotFixture(); + const nativeSnapshot: MicroSnapshot = { + ...snapshotFixture(), + usage: { + windows: [{ + id: "five-hour", kind: "five-hour", usedPercent: 25, remainingPercent: 75, + windowDurationMins: 300, resetsAt: null + }], + observedAt: 10_000, resetCreditsAvailable: null, resetCreditsApplicable: null + } + }; const testBridge = bridge as unknown as { ensureConnected: () => Promise; evaluate: (expression: string) => Promise; @@ -104,6 +113,73 @@ test("forced usage query failures reject while normal background refresh remains assert.equal(await read(normal, false, 20_000, {}), "last-known"); }); +test("forced usage accepts only a normalized rate-limit window after fetch", async () => { + const normalizeRendererUsage = Reflect.get(microBridgeModule, "normalizeRendererUsage") as unknown; + assert.equal(typeof normalizeRendererUsage, "function"); + const normalize = normalizeRendererUsage as ( + data: unknown, dataUpdatedAt?: number, now?: number + ) => MicroSnapshot["usage"]; + const readUsageQueryData = Reflect.get(microBridgeModule, "readUsageQueryData") as ( + query: { state: { data?: unknown; dataUpdatedAt: number }; fetch: () => Promise }, + forceUsageRefresh: boolean, + now?: number, + refreshState?: Record + ) => Promise; + const invalidData = [ + undefined, + { account: "present-without-rate-limit" }, + { rate_limit: { primary_window: { used_percent: "unknown", limit_window_seconds: 18_000 } } } + ]; + + for (const data of invalidData) { + const query = { state: { data, dataUpdatedAt: 10_000 }, fetch: async () => undefined }; + const fetched = await readUsageQueryData(query, true, 20_000, {}); + assert.equal(normalize(fetched, query.state.dataUpdatedAt, 20_000), undefined); + } + + const valid = normalize({ + rate_limit: { primary_window: { used_percent: 25, limit_window_seconds: 18_000, reset_at: 30_000 } } + }, 10_000, 20_000); + assert.equal(valid?.windows.length, 1); + assert.equal(valid?.windows[0]?.kind, "five-hour"); + assert.equal(valid?.windows[0]?.remainingPercent, 75); +}); + +test("forced bridge snapshots reject absent or empty normalized usage", async () => { + for (const nativeSnapshot of [ + snapshotFixture(), + { + ...snapshotFixture(), + usage: { + windows: [], observedAt: 10_000, + resetCreditsAvailable: null, resetCreditsApplicable: null + } + }, + { + ...snapshotFixture(), + usage: { + windows: [{ + id: "five-hour", kind: "five-hour", usedPercent: Number.NaN, remainingPercent: 75, + windowDurationMins: 300, resetsAt: null + }], + observedAt: 10_000, resetCreditsAvailable: null, resetCreditsApplicable: null + } + } + ]) { + const bridge = new microBridgeModule.CodexMicroRendererBridge(() => {}); + const testBridge = bridge as unknown as { + ensureConnected: () => Promise; + evaluate: (expression: string) => Promise; + sessionOwnership: { annotate: (value: MicroSnapshot) => Promise }; + }; + testBridge.ensureConnected = async () => {}; + testBridge.evaluate = async (): Promise => structuredClone(nativeSnapshot) as T; + testBridge.sessionOwnership = { annotate: async (value) => value }; + + await assert.rejects(bridge.requestUsageRefresh(), /valid rate-limit usage/); + } +}); + test("renderer bridge prefers the main index document over macOS avatar surfaces", () => { const target = selectCodexMainTarget([ { type: "page", url: "app://-/index.html?initialRoute=%2Favatar-overlay", webSocketDebuggerUrl: "ws://route" }, diff --git a/test/relay.test.ts b/test/relay.test.ts index dc42f92..e635325 100644 --- a/test/relay.test.ts +++ b/test/relay.test.ts @@ -1058,7 +1058,7 @@ test("controller refreshes account usage on its source host instead of the selec assert.match(source, /refreshUsage[\s\S]*supportsCapabilityForSnapshot\("usage-refresh", source\.hostId\)/); assert.match(source, /refreshUsage[\s\S]*\{ kind: "usage-refresh" \}/); assert.match(source, /Remote Codex host does not support usage refresh\./); - assert.match(source, /refreshUsage: \(\) => runAndInvalidate\(\(\) => this\.microBridge\.requestUsageRefresh\(\)\.then\(\(\) => undefined\)\)/); + assert.match(source, /refreshUsage: async \(\) => \{[\s\S]*?await this\.refreshLocalUsage\(\);[\s\S]*?mobileSnapshotDirty = false/); }); test("controller preserves last-known usage and degrades health when forced refresh rejects", async () => { @@ -1095,6 +1095,52 @@ test("controller preserves last-known usage and degrades health when forced refr assert.equal(state.localHealth.reason, "local-bridge-unavailable"); }); +test("forced controller usage wins over a pre-command refresh in flight", async () => { + const { DeckController } = await import("../src/controller.js"); + const controller = new DeckController(); + let releasePreCommand!: (value: MicroSnapshot) => void; + const preCommand = new Promise((resolve) => { releasePreCommand = resolve; }); + const forcedSnapshot: MicroSnapshot = { + ...structuredClone(snapshot), + reasoningEffort: "forced", + usage: { + windows: [{ + id: "five-hour", kind: "five-hour", usedPercent: 20, remainingPercent: 80, + windowDurationMins: 300, resetsAt: 30_000 + }], + observedAt: 20_000, resetCreditsAvailable: 1, resetCreditsApplicable: 1 + } + }; + const state = controller as unknown as { + microBridge: { + refresh: () => Promise; + requestUsageRefresh: () => Promise; + }; + localHost: CodexHost; + localSnapshot?: HostSnapshot; + localHealth: { state: string; changedAt: number; reason?: string }; + refresh: () => Promise; + refreshDisplay: () => Promise; + }; + state.microBridge = { + refresh: async () => preCommand, + requestUsageRefresh: async () => structuredClone(forcedSnapshot) + }; + state.localHost = host; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.refreshDisplay = async () => {}; + + const olderRefresh = state.refresh(); + await controller.refreshUsage(); + assert.equal(state.localSnapshot?.snapshot.reasoningEffort, "forced"); + + releasePreCommand({ ...structuredClone(snapshot), reasoningEffort: "stale-pre-command" }); + await olderRefresh; + assert.equal(state.localSnapshot?.snapshot.reasoningEffort, "forced"); + assert.equal(state.localSnapshot?.snapshot.usage?.observedAt, 20_000); + assert.equal(state.localHealth.state, "ready"); +}); + async function freePort(): Promise { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); From ecbf1c35f3600acd8190f2f3ea5210c029d6a8e5 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 14:37:46 -0400 Subject: [PATCH 011/137] fix: validate refreshed usage scalars --- src/codex-micro-renderer-bridge.ts | 40 ++++++++++++++++++++---------- src/controller.ts | 7 +++--- test/micro-bridge.test.ts | 33 ++++++++++++++++++++++++ test/relay.test.ts | 36 +++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index 9a71585..683ad69 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -120,21 +120,25 @@ export function normalizeRendererUsage( 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 = 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 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"; - const usedPercent = Math.min(100, Math.max(0, used)); return { id: kind === "other" ? `${role}-${String(minutes ?? "unknown")}` : kind, kind, - usedPercent, - remainingPercent: 100 - usedPercent, + usedPercent: used, + remainingPercent: 100 - used, windowDurationMins: minutes, - resetsAt: toEpoch(window.reset_at) ?? null + resetsAt: resetsAt ?? null }; }; const windows = [ @@ -146,13 +150,23 @@ export function normalizeRendererUsage( const creditRecord = credits && typeof credits === "object" && !Array.isArray(credits) ? credits as Record : {}; - const available = Number(creditRecord.available_count); - const applicable = Number(creditRecord.applicable_available_count); + 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: Number.isFinite(dataUpdatedAt) && Number(dataUpdatedAt) > 0 ? Number(dataUpdatedAt) : now, - resetCreditsAvailable: Number.isFinite(available) ? Math.max(0, Math.floor(available)) : null, - resetCreditsApplicable: Number.isFinite(applicable) ? Math.max(0, Math.floor(applicable)) : null + observedAt: typeof dataUpdatedAt === "number" && Number.isFinite(dataUpdatedAt) && dataUpdatedAt > 0 + ? dataUpdatedAt + : now, + resetCreditsAvailable: available.value, + resetCreditsApplicable: applicable.value }; } diff --git a/src/controller.ts b/src/controller.ts index eeb1ee1..776fe9f 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -345,9 +345,10 @@ export class DeckController { private async refreshLocalUsage(): Promise { const generation = ++this.localSnapshotGeneration; + let snapshot: MicroSnapshot; try { const host = this.localHost ?? await getOrCreateHostIdentity(); - const snapshot = await this.microBridge.requestUsageRefresh(); + snapshot = await this.microBridge.requestUsageRefresh(); if (generation !== this.localSnapshotGeneration) { throw new Error("Codex usage refresh was superseded by a newer refresh."); } @@ -358,8 +359,6 @@ export class DeckController { this.localSnapshot = { host, snapshot, observedAt }; this.localHealth = { state: "ready", changedAt: observedAt }; this.lastError = ""; - await this.refreshDisplay(); - return snapshot; } catch (error) { if (generation !== this.localSnapshotGeneration) throw error; this.localHealth = { state: "degraded", reason: "local-bridge-unavailable", changedAt: Date.now() }; @@ -371,6 +370,8 @@ export class DeckController { await this.refreshDisplay(); throw error; } + await this.refreshDisplay(); + return snapshot; } private async refresh(): Promise { diff --git a/test/micro-bridge.test.ts b/test/micro-bridge.test.ts index 41154f5..0b6e8c2 100644 --- a/test/micro-bridge.test.ts +++ b/test/micro-bridge.test.ts @@ -145,6 +145,39 @@ test("forced usage accepts only a normalized rate-limit window after fetch", asy assert.equal(valid?.windows[0]?.remainingPercent, 75); }); +test("usage normalization rejects coercible and out-of-domain numeric scalars", () => { + const normalize = Reflect.get(microBridgeModule, "normalizeRendererUsage") as ( + data: unknown, dataUpdatedAt?: number, now?: number + ) => MicroSnapshot["usage"]; + const malformedPercentages: unknown[] = [null, "", " ", "25", false, true, [], {}, -1, 101]; + for (const usedPercent of malformedPercentages) { + assert.equal(normalize({ + rate_limit: { primary_window: { used_percent: usedPercent, limit_window_seconds: 18_000 } } + }, 10_000, 20_000), undefined, `used_percent=${String(usedPercent)} must not be coerced`); + } + + const malformedCredits: unknown[] = [null, "", " ", "2", false, true, [], {}, -1, 1.5, Infinity]; + for (const credit of malformedCredits) { + const rate_limit = { primary_window: { used_percent: 25, limit_window_seconds: 18_000 } }; + assert.equal(normalize({ + rate_limit, + rate_limit_reset_credits: { available_count: credit, applicable_available_count: 1 } + }, 10_000, 20_000), undefined, `available_count=${String(credit)} must not be coerced`); + assert.equal(normalize({ + rate_limit, + rate_limit_reset_credits: { available_count: 1, applicable_available_count: credit } + }, 10_000, 20_000), undefined, `applicable_available_count=${String(credit)} must not be coerced`); + } + + const valid = normalize({ + rate_limit: { primary_window: { used_percent: 0, limit_window_seconds: 18_000 } }, + rate_limit_reset_credits: { available_count: 0, applicable_available_count: 2 } + }, 10_000, 20_000); + assert.equal(valid?.windows[0]?.usedPercent, 0); + assert.equal(valid?.resetCreditsAvailable, 0); + assert.equal(valid?.resetCreditsApplicable, 2); +}); + test("forced bridge snapshots reject absent or empty normalized usage", async () => { for (const nativeSnapshot of [ snapshotFixture(), diff --git a/test/relay.test.ts b/test/relay.test.ts index e635325..e842938 100644 --- a/test/relay.test.ts +++ b/test/relay.test.ts @@ -1141,6 +1141,42 @@ test("forced controller usage wins over a pre-command refresh in flight", async assert.equal(state.localHealth.state, "ready"); }); +test("a render failure does not degrade a committed usage refresh", async () => { + const { DeckController } = await import("../src/controller.js"); + const controller = new DeckController(); + const forcedSnapshot: MicroSnapshot = { + ...structuredClone(snapshot), + reasoningEffort: "committed-before-render", + usage: { + windows: [{ + id: "five-hour", kind: "five-hour", usedPercent: 20, remainingPercent: 80, + windowDurationMins: 300, resetsAt: 30_000 + }], + observedAt: 20_000, resetCreditsAvailable: 1, resetCreditsApplicable: 1 + } + }; + let renders = 0; + const renderFailure = new Error("render failed"); + const state = controller as unknown as { + microBridge: { requestUsageRefresh: () => Promise }; + localHost: CodexHost; + localSnapshot?: HostSnapshot; + localHealth: { state: string; changedAt: number; reason?: string }; + refreshDisplay: () => Promise; + }; + state.microBridge = { requestUsageRefresh: async () => structuredClone(forcedSnapshot) }; + state.localHost = host; + state.localHealth = { state: "ready", changedAt: 1_000 }; + state.refreshDisplay = async () => { renders += 1; throw renderFailure; }; + + await assert.rejects(controller.refreshUsage(), renderFailure); + assert.equal(renders, 1); + assert.equal(state.localSnapshot?.snapshot.reasoningEffort, "committed-before-render"); + assert.equal(state.localSnapshot?.snapshot.usage?.observedAt, 20_000); + assert.equal(state.localHealth.state, "ready"); + assert.equal(state.localHealth.reason, undefined); +}); + async function freePort(): Promise { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); From 38e1226f07f20020a650cc068de6dbedfc43646d Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 14:45:28 -0400 Subject: [PATCH 012/137] feat: dispatch configurable Codex dial gestures --- src/controller.ts | 375 ++++++++++++++++++++++++++++++++++- src/dial-domain.ts | 25 ++- test/controller-dial.test.ts | 227 +++++++++++++++++++++ test/dial-domain.test.ts | 31 +++ 4 files changed, 651 insertions(+), 7 deletions(-) create mode 100644 test/controller-dial.test.ts diff --git a/src/controller.ts b/src/controller.ts index 776fe9f..8f64aa7 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1,4 +1,4 @@ -import streamDeck, { type KeyAction } from "@elgato/streamdeck"; +import streamDeck, { type DialAction, type KeyAction } from "@elgato/streamdeck"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { codexDeckStateRoot } from "./codex-deck-paths.js"; @@ -10,7 +10,27 @@ 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 { ADDITIONAL_KEYCAPS, type OfficialKeycapId } from "./keycaps.js"; +import { + DialCommandQueue, + bindingLifecycle, + deriveDialFeedback, + dialBindingLabel, + initialDialRuntimeState, + isDialBindingId, + normalizeDialSettings, + reconcileSelector, + reduceDialRotation, + selectedItem, + selectorItems +} from "./dial-domain.js"; +import type { + CodexDialSettings, + DialBindingId, + DialRuntimeState, + DialRuntimeView, + DialSelectorItem +} from "./dial-types.js"; import { HostActivityIndex, type HostSnapshot, type RelayCommand } from "./relay-protocol.js"; import { renderAgentKey, renderBuiltinKeycap, renderFallbackKeycap, renderHostTargetKey, renderImportedKeycap, @@ -34,6 +54,17 @@ type MicroActionRegistration = { action: KeyAction; slot: MicroActionSlot }; type UsageLimitRegistration = { action: KeyAction; mode: UsageLimitMode }; type ActionIdentity = { id: string }; type ContextRingSettings = { showContextRings?: boolean }; +type CodexDialAction = DialAction; +type DialRegistration = { + action: CodexDialAction; + settings: CodexDialSettings; + state: DialRuntimeState; + queue: DialCommandQueue; + pressed?: { binding: DialBindingId; item?: DialSelectorItem }; + lastFeedback?: string; + renderAgain?: boolean; + rendering?: Promise; +}; const USER_ICON_ROOT = join(codexDeckStateRoot(), "icons"); const LOCAL_MOBILE_CONFIG = "mobile-local-relay-server.json"; @@ -50,7 +81,10 @@ export class DeckController { private readonly usageLimitActions = new Map(); private readonly usageOverviewActions = new Map(); private readonly rateLimitResetActions = new Map(); + private readonly dials = new Map(); private readonly resetHolds = new Map(); + private readonly dialDescriptionErrors = new Set(); + private readonly dialRenderErrors = new Set(); private readonly activityIndex = new HostActivityIndex(); private readonly pressedAgents = new Map(); private readonly pressedControlTargets = new Map(); @@ -219,6 +253,94 @@ export class DeckController { this.lastImages.delete(action.id); } + registerDial(action: CodexDialAction, input: unknown): void { + const registration: DialRegistration = { + action, + settings: normalizeDialSettings(input), + state: initialDialRuntimeState(), + queue: new DialCommandQueue() + }; + this.dials.set(action.id, registration); + this.updateDialDescription(registration); + void this.renderDialSafely(registration); + } + + updateDialSettings(action: CodexDialAction, input: unknown): void { + const settings = normalizeDialSettings(input); + const existing = this.dials.get(action.id); + const registration: DialRegistration = existing ?? { + action, + settings, + state: initialDialRuntimeState(), + queue: new DialCommandQueue() + }; + registration.action = action; + registration.settings = settings; + this.dials.set(action.id, registration); + this.updateDialDescription(registration); + void this.renderDialSafely(registration); + } + + unregisterDial(action: ActionIdentity): void { + this.dials.delete(action.id); + this.resetHolds.delete(action.id); + } + + rotateDial(action: CodexDialAction, ticks: number): void { + const registration = this.dials.get(action.id); + if (!registration) return; + registration.settings = normalizeDialSettings(registration.settings); + 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); + } + for (const binding of reduced.bindings) { + this.enqueueDialCommand(registration, () => this.dispatchDialTap(registration, binding)); + } + } + + 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); + registration.pressed = pressed; + this.enqueueDialCommand( + registration, + () => this.dispatchDialDown(registration, pressed.binding, pressed.item) + ); + 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(); + this.enqueueDialCommand( + registration, + () => this.dispatchDialUp(registration, pressed.binding, pressed.item) + ); + 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 binding = registration.settings.touchTap; + this.enqueueDialCommand(registration, () => this.dispatchDialTap(registration, binding)); + return registration.queue.idle(); + } + registerUsageLimit(action: KeyAction, mode: UsageLimitMode): void { const registration = { action, mode }; this.usageLimitActions.set(action.id, registration); @@ -290,12 +412,15 @@ export class DeckController { await this.renderAll(); } - async sendAgent(slot: number, act: 0 | 1): Promise { + async sendAgent(slot: number, act: 0 | 1, expectedThreadKey?: string): 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 (!assignment.threadKey) throw new Error("The selected Codex task has no stable thread identity."); + if (expectedThreadKey != null && assignment.threadKey !== expectedThreadKey) { + throw new Error("The selected Codex task no longer matches the highlighted task."); + } if (act === 1) this.pressedAgents.set(slot, assignment); else this.pressedAgents.delete(slot); - 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 }); @@ -462,10 +587,250 @@ export class DeckController { ...[...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"] = {}; + 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, + 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, + ...(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 { + 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; + await registration.action.setFeedback({ + title: feedback.title, + value: feedback.value, + detail: feedback.detail, + indicator: feedback.indicator, + accent: { value: 100, bar_fill_c: feedback.accent } + }); + registration.lastFeedback = signature; + } + + private async renderDialSafely(registration: DialRegistration): Promise { + 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 (!this.dialRenderErrors.has(message)) { + this.dialRenderErrors.add(message); + streamDeck.logger.error(`Codex dial feedback unavailable: ${message}`); + } + } + } while (registration.renderAgain); + })(); + registration.rendering = rendering; + try { await rendering; } + finally { + if (registration.rendering === rendering) registration.rendering = undefined; + } + } + + private updateDialDescription(registration: DialRegistration): void { + 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 (this.dialDescriptionErrors.has(message)) return; + this.dialDescriptionErrors.add(message); + streamDeck.logger.warn(`Codex dial trigger descriptions unavailable: ${message}`); + }); + } + + private resolveDialPress( + registration: DialRegistration + ): { binding: DialBindingId; item?: DialSelectorItem } { + const binding = registration.settings.press; + if (binding !== "selector.activate") return { binding }; + const item = selectedItem( + registration.settings, + registration.state, + this.dialRuntimeView(registration.settings, registration.state) + ); + return item ? { binding, item } : { binding }; + } + + private enqueueDialCommand( + registration: DialRegistration, + operation: () => Promise + ): void { + registration.queue.enqueue(async () => { + try { + await operation(); + } catch (error) { + 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 dispatchDialTap( + registration: DialRegistration, + binding: DialBindingId, + item?: DialSelectorItem + ): Promise { + const lifecycle = bindingLifecycle(binding); + if (lifecycle === "momentary" || binding === "selector.activate") { + await this.dispatchDialBinding(registration, binding, 1, item); + await this.dispatchDialBinding(registration, binding, 0, item); + return; + } + if (lifecycle === "hold") throw new Error("Rate-limit reset requires a dial press and hold."); + await this.dispatchDialBinding(registration, binding, 1, item); + } + + private async dispatchDialDown( + registration: DialRegistration, + binding: DialBindingId, + item?: DialSelectorItem + ): Promise { + if (bindingLifecycle(binding) === "hold") { + this.beginRateLimitReset(registration.action); + return; + } + await this.dispatchDialBinding(registration, binding, 1, item); + } + + private async dispatchDialUp( + registration: DialRegistration, + binding: DialBindingId, + item?: DialSelectorItem + ): Promise { + if (bindingLifecycle(binding) === "hold") { + const reset = await this.finishRateLimitReset(registration.action); + if (reset) { + const action = registration.action as CodexDialAction & { + showOk?: () => Promise; + }; + await action.showOk?.(); + } + return; + } + if (bindingLifecycle(binding) === "momentary" || binding === "selector.activate") { + await this.dispatchDialBinding(registration, binding, 0, item); + } + } + + private async dispatchDialBinding( + registration: DialRegistration, + binding: DialBindingId, + act: 0 | 1, + item?: DialSelectorItem + ): Promise { + if (!isDialBindingId(binding, "press")) throw new Error("Unsupported Codex dial binding."); + if (binding === "none") return; + if (binding === "selector.activate") { + if (item?.agentSlot != null && item.threadKey) { + await this.sendAgent(item.agentSlot, act, item.threadKey); + return; + } + if (item?.binding && isDialBindingId(item.binding, "selector")) { + await this.dispatchDialBinding(registration, item.binding, act); + return; + } + throw new Error("No Codex dial selection is available."); + } + const lifecycle = bindingLifecycle(binding); + if (act === 0 && lifecycle !== "momentary") return; + if (binding === "reasoning.decrease") return this.adjustReasoning("decrease"); + if (binding === "reasoning.increase") return this.adjustReasoning("increase"); + if (binding === "new-task") return this.createTask(); + if (binding === "host.toggle") return this.toggleTargetHost(); + if (binding === "usage.refresh") return this.refreshUsage(); + 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.")) { + return this.sendMicroAction(binding.slice(6) as MicroActionSlot, act); + } + if (binding.startsWith("joystick.")) { + return this.sendJoystick(binding.slice(9) as MicroDirection, act); + } + if (binding.startsWith("keycap.") && act === 1) { + return this.runKeycap(binding.slice(7) as OfficialKeycapId); + } + throw new Error("Unsupported Codex dial binding."); + } + private async renderAgent({ action, slot }: AgentRegistration): Promise { const agent = this.routedSlots[slot]; const health = agent ? this.healthForHost(agent.host) : this.targetHealth(); diff --git a/src/dial-domain.ts b/src/dial-domain.ts index 9fa7243..384989a 100644 --- a/src/dial-domain.ts +++ b/src/dial-domain.ts @@ -23,6 +23,27 @@ export const DEFAULT_ACTION_SELECTOR_ITEMS: readonly DialBindingId[] = Object.fr MICRO_SLOTS.map((slot): DialBindingId => `micro.${slot}`) ); +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; + } +} + 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); @@ -71,7 +92,7 @@ export function selectorItems( } return settings.rotation.items.map((binding) => ({ id: binding, - label: cleanActionLabel(view.actionLabels[binding]) ?? humanBindingLabel(binding), + label: cleanActionLabel(view.actionLabels[binding]) ?? dialBindingLabel(binding), binding })); } @@ -324,7 +345,7 @@ function cleanActionLabel(value: string | undefined): string | undefined { return clean || undefined; } -function humanBindingLabel(binding: DialBindingId): string { +export function dialBindingLabel(binding: DialBindingId): string { const fixed: Partial> = { none: "None", "selector.activate": "Activate Selection", diff --git a/test/controller-dial.test.ts b/test/controller-dial.test.ts new file mode 100644 index 0000000..2ec9974 --- /dev/null +++ b/test/controller-dial.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { DialAction } from "@elgato/streamdeck"; +import { DeckController } from "../src/controller.js"; +import { expandDialPreset, type DialCommandQueue } from "../src/dial-domain.js"; +import type { CodexDialSettings, DialRuntimeState } from "../src/dial-types.js"; +import type { CodexHost, RoutedAgentSlot } from "../src/types.js"; + +type FakeDial = DialAction & { + feedbackCalls: unknown[]; + triggerCalls: unknown[]; + alerts: number; + oks: number; +}; + +type DialRegistrationProbe = { + settings: CodexDialSettings; + state: DialRuntimeState; + queue: DialCommandQueue; +}; + +type ControllerProbe = { + dials: Map; + routedSlots: RoutedAgentSlot[]; + localHost?: CodexHost; + microBridge: { + sendAgent(slot: number, act: 0 | 1, threadKey?: string): Promise; + }; +}; + +const HOST: CodexHost = { + hostId: "host-a", + hostName: "Mac", + platform: "darwin" +}; + +function fakeDial(id: string, options: { rejectDescriptions?: boolean } = {}): FakeDial { + const dial = { + id, + feedbackCalls: [] as unknown[], + triggerCalls: [] as unknown[], + alerts: 0, + oks: 0, + async setFeedback(payload: unknown) { this.feedbackCalls.push(payload); }, + async setTriggerDescription(payload: unknown) { + this.triggerCalls.push(payload); + if (options.rejectDescriptions) throw new Error("description unavailable"); + }, + async showAlert() { this.alerts += 1; }, + async showOk() { this.oks += 1; } + }; + return dial as unknown as FakeDial; +} + +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("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]> = []; + probe(controller).routedSlots = [routedAgent(0, "thread-a"), routedAgent(1, "thread-b")]; + controller.sendAgent = async (slot, act, expectedThreadKey) => { + calls.push([slot, act, expectedThreadKey]); + }; + + 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("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[] = []; + controller.adjustReasoning = async () => { + attempts.push(`attempt-${attempts.length + 1}`); + if (attempts.length === 1) throw new Error("first detent failed"); + }; + + 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("rate-limit reset remains press-only and reports success only after a completed hold", async () => { + const controller = new DeckController(); + const action = fakeDial("protected-hold"); + const settings = { + ...expandDialPreset("custom"), + press: "usage.rate-limit-reset", + touchTap: "usage.rate-limit-reset" + }; + 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.equal(action.oks, 0, "a short hold is a no-op"); + await controller.beginDialPress(action); + await controller.finishDialPress(action); + assert.equal(action.oks, 1); +}); + +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"] + ]); +}); + +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[] = []; + controller.runKeycap = async (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"]); +}); diff --git a/test/dial-domain.test.ts b/test/dial-domain.test.ts index 5f1092b..32ec2ae 100644 --- a/test/dial-domain.test.ts +++ b/test/dial-domain.test.ts @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { DEFAULT_ACTION_SELECTOR_ITEMS, + DialCommandQueue, JOYSTICK_DIRECTIONS, MICRO_SLOTS, + bindingLifecycle, deriveDialFeedback, expandDialPreset, initialDialRuntimeState, @@ -94,6 +96,35 @@ function actionSelector( }); } +test("binding lifecycle distinguishes momentary, one-shot, and protected hold commands", () => { + assert.equal(bindingLifecycle("none"), "none"); + 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]); +}); + +test("serialized queue recovers after a rejected operation", async () => { + const seen: string[] = []; + const queue = new DialCommandQueue(); + queue.enqueue(async () => { seen.push("failed"); throw new Error("expected"); }); + queue.enqueue(async () => { seen.push("recovered"); }); + await queue.idle(); + assert.deepEqual(seen, ["failed", "recovered"]); +}); + test("runtime selectors expose occupied agents, exact usage choices, and ordered safe actions", () => { const agentSettings = expandDialPreset("agents"); assert.deepEqual(selectorItems(agentSettings, RUNTIME_VIEW), [ From c6f85f2e300f22a0ae5ec8f9173aee231e4dda44 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 15:02:56 -0400 Subject: [PATCH 013/137] fix: isolate Codex dial gesture lifecycles --- src/controller.ts | 601 ++++++++++++++++++++++++++++------- test/controller-dial.test.ts | 356 ++++++++++++++++++++- 2 files changed, 835 insertions(+), 122 deletions(-) diff --git a/src/controller.ts b/src/controller.ts index 8f64aa7..22d39d3 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -55,20 +55,44 @@ type UsageLimitRegistration = { action: KeyAction; mode: UsageLimitMode }; type ActionIdentity = { id: string }; type ContextRingSettings = { showContextRings?: boolean }; type CodexDialAction = DialAction; +type DialHostRoute = { kind: "host"; hostId?: string; platform: ControlTarget }; +type DialAgentRoute = { + kind: "agent"; + globalSlot: number; + assignment: RoutedAgentSlot; + identity: string; +}; +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; + lifecycle: ReturnType; + startedAt: number; + endedAt?: number; + phase: DialGesturePhase; +}; type DialRegistration = { action: CodexDialAction; settings: CodexDialSettings; state: DialRuntimeState; queue: DialCommandQueue; - pressed?: { binding: DialBindingId; item?: DialSelectorItem }; + generation: number; + disposed: boolean; + pressed?: DialGesture; lastFeedback?: string; renderAgain?: boolean; rendering?: Promise; + successActive?: boolean; + successTimer?: NodeJS.Timeout; }; +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; export class DeckController { private readonly microBridge = new CodexMicroRendererBridge((message) => streamDeck.logger.info(message)); @@ -82,7 +106,7 @@ export class DeckController { private readonly usageOverviewActions = new Map(); private readonly rateLimitResetActions = new Map(); private readonly dials = new Map(); - private readonly resetHolds = new Map(); + private readonly resetHolds = new Map(); private readonly dialDescriptionErrors = new Set(); private readonly dialRenderErrors = new Set(); private readonly activityIndex = new HostActivityIndex(); @@ -110,6 +134,7 @@ export class DeckController { private lastAgentSourceSignature = ""; private lastHostHealthSignature = ""; private showContextRings = true; + private nextDialGeneration = 0; async start(): Promise { this.stopped = false; @@ -254,11 +279,15 @@ export class DeckController { } 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() + queue: new DialCommandQueue(), + generation: ++this.nextDialGeneration, + disposed: false }; this.dials.set(action.id, registration); this.updateDialDescription(registration); @@ -267,12 +296,18 @@ export class DeckController { updateDialSettings(action: CodexDialAction, input: unknown): void { const settings = normalizeDialSettings(input); - const existing = this.dials.get(action.id); + 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() + queue: new DialCommandQueue(), + generation: ++this.nextDialGeneration, + disposed: false }; registration.action = action; registration.settings = settings; @@ -282,8 +317,36 @@ export class DeckController { } unregisterDial(action: ActionIdentity): void { - this.dials.delete(action.id); - this.resetHolds.delete(action.id); + const registration = this.dials.get(action.id); + if (!registration || registration.action !== action) return; + this.disposeDialRegistration(registration); + } + + private disposeDialRegistration(registration: DialRegistration): void { + if (registration.disposed) return; + registration.disposed = true; + registration.generation = -Math.abs(registration.generation); + registration.renderAgain = false; + if (registration.successTimer) clearTimeout(registration.successTimer); + registration.successActive = false; + registration.successTimer = undefined; + this.resetHolds.delete(registration.action.id); + if (this.dials.get(registration.action.id) === registration) { + this.dials.delete(registration.action.id); + } + const pressed = registration.pressed; + registration.pressed = undefined; + if (!pressed) return; + if (pressed.phase === "active" && pressed.lifecycle === "momentary") { + registration.queue.enqueue(() => this.releaseDialGesture(registration, pressed, false)); + } else if (pressed.lifecycle === "hold") { + pressed.phase = "canceled"; + } + } + + private isCurrentDialRegistration(registration: DialRegistration): boolean { + return !registration.disposed && registration.generation > 0 && + this.dials.get(registration.action.id) === registration; } rotateDial(action: CodexDialAction, ticks: number): void { @@ -300,8 +363,10 @@ export class DeckController { if (registration.settings.rotation.kind === "selector") { void this.renderDialSafely(registration); } + const startedAt = Date.now(); for (const binding of reduced.bindings) { - this.enqueueDialCommand(registration, () => this.dispatchDialTap(registration, binding)); + const gesture = this.captureDialGesture(registration, binding, undefined, startedAt); + this.enqueueDialTap(registration, gesture); } } @@ -310,12 +375,9 @@ export class DeckController { if (!registration) return Promise.resolve(); if (registration.pressed) return registration.queue.idle(); registration.settings = normalizeDialSettings(registration.settings); - const pressed = this.resolveDialPress(registration); + const pressed = this.resolveDialPress(registration, Date.now()); registration.pressed = pressed; - this.enqueueDialCommand( - registration, - () => this.dispatchDialDown(registration, pressed.binding, pressed.item) - ); + this.enqueueDialDown(registration, pressed); return registration.queue.idle(); } @@ -325,10 +387,8 @@ export class DeckController { const pressed = registration.pressed; registration.pressed = undefined; if (!pressed) return registration.queue.idle(); - this.enqueueDialCommand( - registration, - () => this.dispatchDialUp(registration, pressed.binding, pressed.item) - ); + pressed.endedAt = Date.now(); + this.enqueueDialUp(registration, pressed); return registration.queue.idle(); } @@ -336,8 +396,13 @@ export class DeckController { const registration = this.dials.get(action.id); if (!registration) return Promise.resolve(); registration.settings = normalizeDialSettings(registration.settings); - const binding = registration.settings.touchTap; - this.enqueueDialCommand(registration, () => this.dispatchDialTap(registration, binding)); + const gesture = this.captureDialGesture( + registration, + registration.settings.touchTap, + undefined, + Date.now() + ); + this.enqueueDialTap(registration, gesture); return registration.queue.idle(); } @@ -376,23 +441,49 @@ 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); } - async finishRateLimitReset(action: ActionIdentity): Promise { - const startedAt = this.resetHolds.get(action.id); + async finishRateLimitReset( + action: ActionIdentity, + endedAt = Date.now(), + sourceHostId?: string + ): 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); 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()); + 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; } @@ -419,11 +510,19 @@ export class DeckController { if (expectedThreadKey != null && assignment.threadKey !== expectedThreadKey) { throw new Error("The selected Codex task no longer matches the highlighted task."); } - if (act === 1) this.pressedAgents.set(slot, assignment); - else this.pressedAgents.delete(slot); - 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 === 1) this.pressedAgents.delete(slot); + try { + 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 === 1) this.pressedAgents.set(slot, assignment); + } finally { + if (act === 0) this.pressedAgents.delete(slot); + } if (act === 0) void this.refresh(); } @@ -644,6 +743,7 @@ export class DeckController { } private async renderDial(registration: DialRegistration): Promise { + if (!this.isCurrentDialRegistration(registration) || registration.successActive) return; registration.settings = normalizeDialSettings(registration.settings); const view = this.dialRuntimeView(registration.settings, registration.state); if (registration.settings.rotation.kind === "selector") { @@ -655,6 +755,7 @@ export class DeckController { 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, @@ -662,10 +763,11 @@ export class DeckController { indicator: feedback.indicator, accent: { value: 100, bar_fill_c: feedback.accent } }); - registration.lastFeedback = signature; + 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; @@ -683,16 +785,21 @@ export class DeckController { streamDeck.logger.error(`Codex dial feedback unavailable: ${message}`); } } - } while (registration.renderAgain); + } 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); @@ -705,109 +812,256 @@ export class DeckController { }); } - private resolveDialPress( - registration: DialRegistration - ): { binding: DialBindingId; item?: DialSelectorItem } { + private async showDialSuccess(registration: DialRegistration): Promise { + if (!this.isCurrentDialRegistration(registration)) return; + if (registration.successTimer) clearTimeout(registration.successTimer); + registration.successActive = true; + try { + await registration.action.setFeedback({ + title: "RATE LIMIT", + value: "RESET COMPLETE", + detail: "CREDIT APPLIED", + indicator: 100, + accent: { value: 100, bar_fill_c: "#35D86B" } + }); + } catch (error) { + registration.successActive = false; + throw error; + } + if (!this.isCurrentDialRegistration(registration)) return; + registration.successTimer = setTimeout(() => { + if (!this.isCurrentDialRegistration(registration)) return; + registration.successTimer = undefined; + registration.successActive = false; + registration.lastFeedback = undefined; + void this.renderDialSafely(registration); + }, DIAL_SUCCESS_MS); + } + + private resolveDialPress(registration: DialRegistration, startedAt: number): DialGesture { const binding = registration.settings.press; - if (binding !== "selector.activate") return { binding }; + 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 item ? { binding, item } : { binding }; + return this.captureDialGesture(registration, binding, item, startedAt); } - private enqueueDialCommand( + private captureDialGesture( registration: DialRegistration, - operation: () => Promise - ): void { + 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", + globalSlot: item.agentSlot, + 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, + 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 { registration.queue.enqueue(async () => { + if (!this.isCurrentDialRegistration(registration)) { + gesture.phase = "canceled"; + return; + } try { - await operation(); + 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) { - 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)}`); + gesture.phase = "failed"; + if (this.isCurrentDialRegistration(registration)) { + await this.reportDialCommandError(registration, error); } } }); } - private async dispatchDialTap( - registration: DialRegistration, - binding: DialBindingId, - item?: DialSelectorItem - ): Promise { - const lifecycle = bindingLifecycle(binding); - if (lifecycle === "momentary" || binding === "selector.activate") { - await this.dispatchDialBinding(registration, binding, 1, item); - await this.dispatchDialBinding(registration, binding, 0, item); - return; - } - if (lifecycle === "hold") throw new Error("Rate-limit reset requires a dial press and hold."); - await this.dispatchDialBinding(registration, binding, 1, item); + private enqueueDialUp(registration: DialRegistration, gesture: DialGesture): void { + registration.queue.enqueue(async () => { + if (gesture.phase !== "active") return; + if (gesture.lifecycle === "hold") { + gesture.phase = "releasing"; + try { + const sourceHostId = gesture.route.kind === "host" ? gesture.route.hostId : undefined; + const reset = await this.finishRateLimitReset( + registration.action, + gesture.endedAt ?? gesture.startedAt, + sourceHostId + ); + gesture.phase = "released"; + if (reset && this.isCurrentDialRegistration(registration)) { + await this.showDialSuccess(registration); + } + } catch (error) { + gesture.phase = "released"; + if (this.isCurrentDialRegistration(registration)) { + await this.reportDialCommandError(registration, error); + } + } + return; + } + await this.releaseDialGesture( + registration, + gesture, + this.isCurrentDialRegistration(registration) + ); + }); } - private async dispatchDialDown( - registration: DialRegistration, - binding: DialBindingId, - item?: DialSelectorItem - ): Promise { - if (bindingLifecycle(binding) === "hold") { - this.beginRateLimitReset(registration.action); - return; - } - await this.dispatchDialBinding(registration, binding, 1, item); + private enqueueDialTap(registration: DialRegistration, gesture: DialGesture): void { + registration.queue.enqueue(async () => { + if (!this.isCurrentDialRegistration(registration)) { + gesture.phase = "canceled"; + 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); + } + } + }); } - private async dispatchDialUp( + private async releaseDialGesture( registration: DialRegistration, - binding: DialBindingId, - item?: DialSelectorItem + gesture: DialGesture, + reportFailure: boolean ): Promise { - if (bindingLifecycle(binding) === "hold") { - const reset = await this.finishRateLimitReset(registration.action); - if (reset) { - const action = registration.action as CodexDialAction & { - showOk?: () => Promise; - }; - await action.showOk?.(); - } - return; - } - if (bindingLifecycle(binding) === "momentary" || binding === "selector.activate") { - await this.dispatchDialBinding(registration, binding, 0, item); + 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 dispatchDialBinding( + private async dispatchDialGesture( registration: DialRegistration, - binding: DialBindingId, - act: 0 | 1, - item?: DialSelectorItem + 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 (binding === "selector.activate") { - if (item?.agentSlot != null && item.threadKey) { - await this.sendAgent(item.agentSlot, act, item.threadKey); - return; - } - if (item?.binding && isDialBindingId(item.binding, "selector")) { - await this.dispatchDialBinding(registration, item.binding, act); - return; - } - throw new Error("No Codex dial selection is available."); + 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") return this.adjustReasoning("decrease"); - if (binding === "reasoning.increase") return this.adjustReasoning("increase"); - if (binding === "new-task") return this.createTask(); + if (binding === "reasoning.decrease") { + return this.sendDialToHost( + route, + { kind: "reasoning", direction: "decrease" }, + () => this.microBridge.adjustReasoning("decrease") + ); + } + if (binding === "reasoning.increase") { + return this.sendDialToHost( + route, + { kind: "reasoning", direction: "increase" }, + () => this.microBridge.adjustReasoning("increase") + ); + } + if (binding === "new-task") { + return this.sendDialToHost( + route, + { kind: "keycap", keycapId: "NEW" }, + () => openCodexThread("new") + ); + } if (binding === "host.toggle") return this.toggleTargetHost(); - if (binding === "usage.refresh") return this.refreshUsage(); + if (binding === "usage.refresh") return this.refreshDialUsage(route); if (binding === "usage.toggle-overview") { registration.state = { ...registration.state, @@ -820,17 +1074,113 @@ export class DeckController { throw new Error("Rate-limit reset requires a dial press and hold."); } if (binding.startsWith("micro.")) { - return this.sendMicroAction(binding.slice(6) as MicroActionSlot, act); + const slot = binding.slice(6) as MicroActionSlot; + return this.sendDialToHost( + route, + { kind: "action", slot, act }, + () => this.microBridge.sendAction(slot, act) + ); } if (binding.startsWith("joystick.")) { - return this.sendJoystick(binding.slice(9) as MicroDirection, act); + const direction = binding.slice(9) as MicroDirection; + return this.sendDialToHost( + route, + { kind: "joystick", direction, distance: act }, + () => this.microBridge.sendJoystick(direction, act) + ); } if (binding.startsWith("keycap.") && act === 1) { - return this.runKeycap(binding.slice(7) as OfficialKeycapId); + const keycapId = binding.slice(7) as OfficialKeycapId; + return this.sendDialToHost( + route, + { kind: "keycap", keycapId }, + () => this.microBridge.runKeycap(keycapId) + ); } throw new Error("Unsupported Codex dial binding."); } + private async sendDialAgent(route: DialAgentRoute, act: 0 | 1): Promise { + if (act === 1) { + const current = this.routedSlots[route.globalSlot]; + const currentIdentity = current?.threadKey + ? `${current.host.hostId}:${current.threadKey}` + : undefined; + if (currentIdentity !== route.identity || current?.sourceSlot !== route.assignment.sourceSlot) { + throw new Error("The selected Codex task no longer matches the highlighted host and task."); + } + } + const { assignment } = route; + 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); + 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 + }); + } + + private async sendDialToHost( + route: DialHostRoute, + command: RelayCommand, + local: () => Promise + ): Promise { + const localHost = this.localHost; + const localRequested = route.hostId != null + ? route.hostId === localHost?.hostId + : route.platform === localHost?.platform; + if (localRequested) { + await local(); + 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 host is no longer connected."); + } + await this.sendRemote(command); + } + + private async refreshDialUsage(route: DialHostRoute): Promise { + const localRequested = route.hostId != null + ? route.hostId === this.localHost?.hostId + : route.platform === this.localHost?.platform; + if (localRequested) { + 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)) { + 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(); @@ -896,8 +1246,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, @@ -945,6 +1297,41 @@ 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; } diff --git a/test/controller-dial.test.ts b/test/controller-dial.test.ts index 2ec9974..6a569d9 100644 --- a/test/controller-dial.test.ts +++ b/test/controller-dial.test.ts @@ -4,13 +4,12 @@ import type { DialAction } from "@elgato/streamdeck"; import { DeckController } from "../src/controller.js"; import { expandDialPreset, type DialCommandQueue } from "../src/dial-domain.js"; import type { CodexDialSettings, DialRuntimeState } from "../src/dial-types.js"; -import type { CodexHost, RoutedAgentSlot } from "../src/types.js"; +import type { CodexHost, HostHealth, MicroSnapshot, RoutedAgentSlot } from "../src/types.js"; type FakeDial = DialAction & { feedbackCalls: unknown[]; triggerCalls: unknown[]; alerts: number; - oks: number; }; type DialRegistrationProbe = { @@ -23,9 +22,25 @@ type ControllerProbe = { dials: Map; routedSlots: RoutedAgentSlot[]; localHost?: CodexHost; + localSnapshot?: { host: CodexHost; observedAt: number; snapshot: MicroSnapshot }; + localHealth: HostHealth; + targetHostId?: string; + targetPlatform: CodexHost["platform"]; + relayClient?: { + currentHost(): CodexHost | undefined; + currentHealth(): HostHealth; + currentSnapshot(): undefined; + send(command: unknown): Promise; + }; microBridge: { sendAgent(slot: number, act: 0 | 1, threadKey?: string): Promise; + sendAction?(slot: string, act: 0 | 1): Promise; + adjustReasoning?(direction: string): Promise; + runKeycap?(keycapId: string): Promise; + consumeRateLimitReset?(): Promise; }; + pressedAgents: Map; + refresh(): Promise; }; const HOST: CodexHost = { @@ -34,20 +49,49 @@ const HOST: CodexHost = { 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 } = {}): FakeDial { const dial = { id, feedbackCalls: [] as unknown[], triggerCalls: [] as unknown[], alerts: 0, - oks: 0, async setFeedback(payload: unknown) { this.feedbackCalls.push(payload); }, async setTriggerDescription(payload: unknown) { this.triggerCalls.push(payload); if (options.rejectDescriptions) throw new Error("description unavailable"); }, - async showAlert() { this.alerts += 1; }, - async showOk() { this.oks += 1; } + async showAlert() { this.alerts += 1; } }; return dial as unknown as FakeDial; } @@ -119,9 +163,13 @@ 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]> = []; - probe(controller).routedSlots = [routedAgent(0, "thread-a"), routedAgent(1, "thread-b")]; - controller.sendAgent = async (slot, act, expectedThreadKey) => { - calls.push([slot, act, expectedThreadKey]); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + 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")); @@ -140,9 +188,16 @@ test("paired detents continue after a dispatch failure and alert only the failin const controller = new DeckController(); const action = fakeDial("reasoning-errors"); const attempts: string[] = []; - controller.adjustReasoning = async () => { - attempts.push(`attempt-${attempts.length + 1}`); - if (attempts.length === 1) throw new Error("first detent failed"); + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.microBridge = { + async sendAgent() {}, + async adjustReasoning() { + attempts.push(`attempt-${attempts.length + 1}`); + if (attempts.length === 1) throw new Error("first detent failed"); + } }; controller.registerDial(action, expandDialPreset("reasoning")); @@ -153,7 +208,7 @@ test("paired detents continue after a dispatch failure and alert only the failin assert.equal(action.alerts, 1); }); -test("rate-limit reset remains press-only and reports success only after a completed hold", async () => { +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 = { @@ -173,10 +228,16 @@ test("rate-limit reset remains press-only and reports success only after a compl await controller.beginDialPress(action); await controller.finishDialPress(action); - assert.equal(action.oks, 0, "a short hold is a no-op"); + assert.doesNotMatch(JSON.stringify(action.feedbackCalls), /RESET COMPLETE/); await controller.beginDialPress(action); await controller.finishDialPress(action); - assert.equal(action.oks, 1); + 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 () => { @@ -201,6 +262,13 @@ test("agent dispatch validates expected identity before down and releases the sa [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 saved keypad route"); }); test("action selector rotation does not execute until press", async () => { @@ -216,7 +284,14 @@ test("action selector rotation does not execute until press", async () => { } }; const keycaps: string[] = []; - controller.runKeycap = async (keycapId) => { keycaps.push(keycapId); }; + const state = probe(controller); + state.localHost = HOST; + state.targetHostId = HOST.hostId; + state.targetPlatform = HOST.platform; + state.microBridge = { + async sendAgent() {}, + async runKeycap(keycapId) { keycaps.push(keycapId); } + }; controller.registerDial(action, settings); controller.rotateDial(action, 1); @@ -225,3 +300,254 @@ test("action selector rotation does not execute until press", async () => { await controller.beginDialPress(action); assert.deepEqual(keycaps, ["APPR"]); }); + +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.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("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.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.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(oldAction); + assert.equal(probe(controller).dials.has(newAction.id), true, "an old disappear cannot remove the new instance"); +}); + +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.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, []); +}); From a42b7d08af921233578c33bf899ce55a36ab7afe Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 15:12:22 -0400 Subject: [PATCH 014/137] fix: close dial lifecycle integration races --- src/controller.ts | 133 +++++++++++++++++---------- test/controller-dial.test.ts | 171 +++++++++++++++++++++++++++++++++-- 2 files changed, 251 insertions(+), 53 deletions(-) diff --git a/src/controller.ts b/src/controller.ts index 22d39d3..77cf684 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -58,10 +58,14 @@ type CodexDialAction = DialAction; type DialHostRoute = { kind: "host"; hostId?: string; platform: ControlTarget }; type DialAgentRoute = { kind: "agent"; - globalSlot: number; 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"; @@ -109,8 +113,10 @@ export class DeckController { 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; @@ -318,7 +324,7 @@ export class DeckController { unregisterDial(action: ActionIdentity): void { const registration = this.dials.get(action.id); - if (!registration || registration.action !== action) return; + if (!registration) return; this.disposeDialRegistration(registration); } @@ -504,26 +510,68 @@ export class DeckController { } async sendAgent(slot: number, act: 0 | 1, expectedThreadKey?: string): 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 (!assignment.threadKey) throw new Error("The selected Codex task has no stable thread identity."); - if (expectedThreadKey != null && assignment.threadKey !== expectedThreadKey) { - throw new Error("The selected Codex task no longer matches the highlighted task."); + 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; } - if (act === 1) this.pressedAgents.delete(slot); - try { - 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 - }); + + 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 (act === 1) this.pressedAgents.set(slot, assignment); - } finally { - if (act === 0) this.pressedAgents.delete(slot); + if (expectedThreadKey != null && failedThreadKey !== expectedThreadKey) { + throw new Error("The selected Codex task no longer matches the highlighted task."); + } + this.failedAgentUps.delete(slot); + return; } - if (act === 0) void this.refresh(); + 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); + 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 { @@ -826,7 +874,14 @@ export class DeckController { }); } catch (error) { registration.successActive = false; - throw error; + const message = String(error); + if (!this.dialSuccessErrors.has(message)) { + this.dialSuccessErrors.add(message); + streamDeck.logger.warn(`Codex dial success feedback unavailable: ${message}`); + } + registration.lastFeedback = undefined; + await this.renderDialSafely(registration); + return; } if (!this.isCurrentDialRegistration(registration)) return; registration.successTimer = setTimeout(() => { @@ -868,7 +923,6 @@ export class DeckController { route = assignment && identity === item.id ? { kind: "agent", - globalSlot: item.agentSlot, assignment: { ...assignment, host: { ...assignment.host } @@ -946,23 +1000,24 @@ export class DeckController { 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; - const reset = await this.finishRateLimitReset( + reset = await this.finishRateLimitReset( registration.action, gesture.endedAt ?? gesture.startedAt, sourceHostId ); gesture.phase = "released"; - if (reset && this.isCurrentDialRegistration(registration)) { - await this.showDialSuccess(registration); - } } 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( @@ -1102,30 +1157,14 @@ export class DeckController { private async sendDialAgent(route: DialAgentRoute, act: 0 | 1): Promise { if (act === 1) { - const current = this.routedSlots[route.globalSlot]; - const currentIdentity = current?.threadKey - ? `${current.host.hostId}:${current.threadKey}` - : undefined; - if (currentIdentity !== route.identity || current?.sourceSlot !== route.assignment.sourceSlot) { + 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."); } + route.assignment = { ...current, host: { ...current.host } }; } - const { assignment } = route; - 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); - 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 - }); + await this.sendAgentAssignment(route.assignment, act); } private async sendDialToHost( diff --git a/test/controller-dial.test.ts b/test/controller-dial.test.ts index 6a569d9..706f4f0 100644 --- a/test/controller-dial.test.ts +++ b/test/controller-dial.test.ts @@ -39,7 +39,7 @@ type ControllerProbe = { runKeycap?(keycapId: string): Promise; consumeRateLimitReset?(): Promise; }; - pressedAgents: Map; + pressedAgents: Map; refresh(): Promise; }; @@ -80,13 +80,21 @@ const SNAPSHOT: MicroSnapshot = { } }; -function fakeDial(id: string, options: { rejectDescriptions?: boolean } = {}): FakeDial { +function fakeDial( + id: string, + options: { rejectDescriptions?: boolean; rejectSuccessFeedback?: boolean } = {} +): FakeDial { const dial = { id, feedbackCalls: [] as unknown[], triggerCalls: [] as unknown[], alerts: 0, - async setFeedback(payload: unknown) { this.feedbackCalls.push(payload); }, + async setFeedback(payload: unknown) { + this.feedbackCalls.push(payload); + if (options.rejectSuccessFeedback && JSON.stringify(payload).includes("RESET COMPLETE")) { + throw new Error("success feedback unavailable"); + } + }, async setTriggerDescription(payload: unknown) { this.triggerCalls.push(payload); if (options.rejectDescriptions) throw new Error("description unavailable"); @@ -184,6 +192,35 @@ test("agent selector release uses the identity resolved on dial down", async () ]); }); +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.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"); @@ -268,7 +305,58 @@ test("agent dispatch validates expected identity before down and releases the sa 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 saved keypad route"); + 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 () => { @@ -411,6 +499,32 @@ test("agent down rejects an owner change with the same thread key and leaves no 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); @@ -511,8 +625,53 @@ test("unregister cancels pending work, releases active gestures, and isolates re 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(oldAction); - assert.equal(probe(controller).dials.has(newAction.id), true, "an old disappear cannot remove the new instance"); + 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("rotation detents use the host captured before their queue backlog", async () => { From 2aa8b4ed14111b1d118ea372c73b4147764a4a2d Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 15:20:22 -0400 Subject: [PATCH 015/137] feat: register the Stream Deck Plus Codex Dial --- scripts/build.mjs | 4 + src/dial-action.ts | 46 +++++++ src/plugin.ts | 2 + static/imgs/dial.svg | 5 + static/imgs/dial@2x.svg | 5 + static/layouts/codex-dial.json | 11 ++ static/manifest.json | 21 ++- static/property-inspector/codex-dial.html | 20 +++ test/dial-action.test.ts | 148 ++++++++++++++++++++++ 9 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/dial-action.ts create mode 100644 static/imgs/dial.svg create mode 100644 static/imgs/dial@2x.svg create mode 100644 static/layouts/codex-dial.json create mode 100644 static/property-inspector/codex-dial.html create mode 100644 test/dial-action.test.ts 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/dial-action.ts b/src/dial-action.ts new file mode 100644 index 0000000..436fdb5 --- /dev/null +++ b/src/dial-action.ts @@ -0,0 +1,46 @@ +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); + } +} 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/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..249ab25 --- /dev/null +++ b/static/property-inspector/codex-dial.html @@ -0,0 +1,20 @@ + + + + + + Codex Dial + + + + + diff --git a/test/dial-action.test.ts b/test/dial-action.test.ts new file mode 100644 index 0000000..4e5a343 --- /dev/null +++ b/test/dial-action.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { access, readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import test from "node:test"; +import type { DeckController } from "../src/controller.js"; +import { CodexDialAction } from "../src/dial-action.js"; + +const execFileAsync = promisify(execFile); +const text = (path: string) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +type ManifestAction = { + UUID: string; + Controllers?: string[]; + Encoder?: { + Icon?: string; + layout?: string; + TriggerDescription?: Record; + }; + PropertyInspectorPath?: string; +}; + +type ControllerCall = { method: string; action: unknown; payload?: unknown }; + +function adapterHarness(): { adapter: CodexDialAction; calls: ControllerCall[] } { + const calls: ControllerCall[] = []; + const controller = { + registerDial(action: unknown, settings: unknown) { calls.push({ method: "register", action, payload: settings }); }, + updateDialSettings(action: unknown, settings: unknown) { calls.push({ method: "settings", action, payload: settings }); }, + unregisterDial(action: unknown) { calls.push({ method: "unregister", action }); }, + rotateDial(action: unknown, ticks: number) { calls.push({ method: "rotate", action, payload: ticks }); }, + async beginDialPress(action: unknown) { calls.push({ method: "down", action }); }, + async finishDialPress(action: unknown) { calls.push({ method: "up", action }); }, + async touchDial(action: unknown) { calls.push({ method: "touch", action }); } + }; + return { + adapter: new CodexDialAction(controller as unknown as DeckController), + calls + }; +} + +test("manifest adds exactly one Encoder-only Codex Dial without changing keypad actions", async () => { + const manifest = JSON.parse(await text("static/manifest.json")) as { Version: string; Actions: ManifestAction[] }; + const uuids = manifest.Actions.map(({ UUID }) => UUID); + const dials = manifest.Actions.filter(({ UUID }) => UUID === "com.simeo.codex-deck.codex-dial"); + + assert.equal(manifest.Version, "0.7.0.2"); + assert.equal(manifest.Actions.length, 54, "the existing 53 actions plus Codex Dial remain present"); + assert.equal(new Set(uuids).size, uuids.length, "every action UUID is unique"); + assert.equal(dials.length, 1); + assert.deepEqual(dials[0]?.Controllers, ["Encoder"]); + assert.equal(dials[0]?.Encoder?.Icon, "static/imgs/dial"); + assert.equal(dials[0]?.Encoder?.layout, "static/layouts/codex-dial.json"); + assert.equal(dials[0]?.PropertyInspectorPath, "static/property-inspector/codex-dial.html"); + assert.deepEqual(Object.keys(dials[0]?.Encoder?.TriggerDescription ?? {}).sort(), ["Push", "Rotate", "Touch"]); + for (const action of manifest.Actions.filter(({ UUID }) => UUID !== "com.simeo.codex-deck.codex-dial")) { + assert.deepEqual(action.Controllers, ["Keypad"], `${action.UUID} remains keypad-only`); + } +}); + +test("Codex Dial adapter forwards all Encoder event families and ignores held touch", async () => { + const { adapter, calls } = adapterHarness(); + const dial = { id: "dial-1", isDial: () => true }; + const settings = { version: 1, preset: "reasoning" }; + + adapter.onWillAppear({ action: dial, payload: { settings } } as never); + adapter.onDidReceiveSettings({ action: dial, payload: { settings } } as never); + adapter.onDialRotate({ action: dial, payload: { ticks: -2 } } as never); + await adapter.onDialDown({ action: dial } as never); + await adapter.onDialUp({ action: dial } as never); + await adapter.onTouchTap({ action: dial, payload: { hold: false } } as never); + await adapter.onTouchTap({ action: dial, payload: { hold: true } } as never); + adapter.onWillDisappear({ action: dial } as never); + + assert.equal(adapter.manifestId, "com.simeo.codex-deck.codex-dial"); + assert.deepEqual(calls.map(({ method }) => method), [ + "register", "settings", "rotate", "down", "up", "touch", "unregister" + ]); + assert.equal(calls[0]?.action, dial); + assert.equal(calls[0]?.payload, settings); + assert.equal(calls[2]?.payload, -2); +}); + +test("Codex Dial registration paths reject non-dial actions", () => { + const { adapter, calls } = adapterHarness(); + const key = { id: "key-1", isDial: () => false }; + + adapter.onWillAppear({ action: key, payload: { settings: {} } } as never); + adapter.onDidReceiveSettings({ action: key, payload: { settings: {} } } as never); + + assert.deepEqual(calls, []); + assert.equal("onKeyDown" in adapter, false); + assert.equal("onKeyUp" in adapter, false); +}); + +test("plugin registers Codex Dial exactly once", async () => { + const source = await text("src/plugin.ts"); + assert.equal(source.match(/new CodexDialAction\(controller\)/g)?.length, 1); +}); + +test("custom layout has five unique typed keys inside the 200 by 100 Encoder canvas", async () => { + const layout = JSON.parse(await text("static/layouts/codex-dial.json")) as { + id: string; + items: Array<{ key: string; type: string; rect: number[] }>; + }; + const expectedTypes = new Map([ + ["accent", "bar"], ["detail", "text"], ["indicator", "bar"], ["title", "text"], ["value", "text"] + ]); + const keys = layout.items.map(({ key }) => key); + + assert.equal(layout.id, "com.simeo.codex-deck.codex-dial.layout"); + assert.equal(layout.items.length, 5); + assert.equal(new Set(keys).size, keys.length); + assert.deepEqual([...keys].sort(), [...expectedTypes.keys()].sort()); + for (const { key, type, rect } of layout.items) { + assert.equal(type, expectedTypes.get(key)); + assert.equal(rect.length, 4); + const [x, y, width, height] = rect; + assert.ok(x !== undefined && y !== undefined && width !== undefined && height !== undefined); + assert.ok(x >= 0 && y >= 0 && width >= 0 && height >= 0); + assert.ok(x + width <= 200 && y + height <= 100, `${key} stays within the Encoder canvas`); + } +}); + +test("dial artwork and the Task 5 inspector shell are self-contained", async () => { + const [dial, dial2x, inspector] = await Promise.all([ + text("static/imgs/dial.svg"), + text("static/imgs/dial@2x.svg"), + text("static/property-inspector/codex-dial.html") + ]); + + assert.match(dial, /width="72" height="72"/); + assert.match(dial2x, /width="144" height="144"/); + assert.doesNotMatch(`${dial}${dial2x}`, / { + await execFileAsync(process.execPath, ["scripts/build.mjs"], { cwd: new URL("..", import.meta.url) }); + for (const path of [ + "dist/com.simeo.codex-deck.sdPlugin/static/imgs/dial.svg", + "dist/com.simeo.codex-deck.sdPlugin/static/imgs/dial@2x.svg", + "dist/com.simeo.codex-deck.sdPlugin/static/layouts/codex-dial.json", + "dist/com.simeo.codex-deck.sdPlugin/static/property-inspector/codex-dial.html" + ]) await access(new URL(`../${path}`, import.meta.url)); +}); From e7dc640262aeb5764a904f25fe55733ef0b58889 Mon Sep 17 00:00:00 2001 From: TLK3 Date: Sun, 9 Aug 2026 15:27:34 -0400 Subject: [PATCH 016/137] test: keep dial packaging checks side-effect free --- test/dial-action.test.ts | 58 +++++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/test/dial-action.test.ts b/test/dial-action.test.ts index 4e5a343..1603db9 100644 --- a/test/dial-action.test.ts +++ b/test/dial-action.test.ts @@ -1,12 +1,10 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { access, readFile } from "node:fs/promises"; -import { promisify } from "node:util"; +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"; -const execFileAsync = promisify(execFile); const text = (path: string) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); type ManifestAction = { @@ -39,14 +37,17 @@ function adapterHarness(): { adapter: CodexDialAction; calls: ControllerCall[] } }; } -test("manifest adds exactly one Encoder-only Codex Dial without changing keypad actions", async () => { - const manifest = JSON.parse(await text("static/manifest.json")) as { Version: string; Actions: ManifestAction[] }; +test("manifest adds exactly one Encoder-only Codex Dial and covers every declared action", async () => { + const manifest = JSON.parse(await text("static/manifest.json")) as { Actions: ManifestAction[] }; const uuids = manifest.Actions.map(({ UUID }) => UUID); const dials = manifest.Actions.filter(({ UUID }) => UUID === "com.simeo.codex-deck.codex-dial"); + const actionSources = await Promise.all([text("src/actions.ts"), text("src/dial-action.ts")]); + const declaredUuids = [...actionSources.join("\n").matchAll(/@action\(\{ UUID: "([^"]+)" \}\)/g)] + .map(([, uuid]) => uuid) + .filter((uuid): uuid is string => uuid !== undefined); - assert.equal(manifest.Version, "0.7.0.2"); - assert.equal(manifest.Actions.length, 54, "the existing 53 actions plus Codex Dial remain present"); assert.equal(new Set(uuids).size, uuids.length, "every action UUID is unique"); + assert.deepEqual([...uuids].sort(), [...declaredUuids].sort(), "manifest and declared actions stay in parity"); assert.equal(dials.length, 1); assert.deepEqual(dials[0]?.Controllers, ["Encoder"]); assert.equal(dials[0]?.Encoder?.Icon, "static/imgs/dial"); @@ -137,12 +138,37 @@ test("dial artwork and the Task 5 inspector shell are self-contained", async () assert.doesNotMatch(inspector, / { - await execFileAsync(process.execPath, ["scripts/build.mjs"], { cwd: new URL("..", import.meta.url) }); - for (const path of [ - "dist/com.simeo.codex-deck.sdPlugin/static/imgs/dial.svg", - "dist/com.simeo.codex-deck.sdPlugin/static/imgs/dial@2x.svg", - "dist/com.simeo.codex-deck.sdPlugin/static/layouts/codex-dial.json", - "dist/com.simeo.codex-deck.sdPlugin/static/property-inspector/codex-dial.html" - ]) await access(new URL(`../${path}`, import.meta.url)); +test("minimal inspector opens loopback WebSocket and sends the Stream Deck registration payload", async () => { + const inspector = await text("static/property-inspector/codex-dial.html"); + const script = inspector.match(/ diff --git a/test/dial-action.test.ts b/test/dial-action.test.ts index 1603db9..f2091c3 100644 --- a/test/dial-action.test.ts +++ b/test/dial-action.test.ts @@ -4,6 +4,7 @@ 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 } from "../src/dial-domain.js"; const text = (path: string) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); @@ -20,6 +21,145 @@ type ManifestAction = { 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>>(); + parentElement: FakeElement | null = null; + value = ""; + checked = false; + disabled = false; + hidden = false; + type = ""; + textContent = ""; + className = ""; + maxLength = -1; + + constructor(readonly tagName: string, readonly id = "") {} + + get options(): FakeElement[] { + return this.descendants().filter(({ tagName }) => tagName === "OPTION"); + } + + 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); + } + + 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|div|section|label|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[]; + open(): void; + message(payload: unknown): void; +}; + +async function inspectorHarness(): Promise<{ + document: FakeDocument; + sockets: FakeSocket[]; + connect: (...args: string[]) => void; +}> { + const source = await text("static/property-inspector/codex-dial.html"); + const script = source.match(/