diff --git a/CHANGELOG.md b/CHANGELOG.md index e794147..5253d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.6.1 - 2026-07-18 + +- Fixed a macOS restart loop after an unexpected shutdown when an authenticated Windows relay requested a snapshot before the Codex Micro signals were ready. +- The macOS watcher no longer launches Codex while the app is closed, waits for an unbridged process to stabilize, and uses a generation-independent recovery cooldown to prevent PID-to-PID restart loops. +- Added safe relay error handling, rate-limited offline diagnostics, and a dedicated macOS watcher stderr log. +- Fixed macOS Codex updates exposing avatar-overlay renderer targets before the real main window, which could stop relay snapshots and leave an agent key stuck in `working`. +- Fixed remote agent commands for nested `local:client-new-thread:` task identities being rejected by the relay validator. +- Read the live agent-source setting directly from Codex instead of falling back to Recently updated after app updates. +- Added mode-aware combined agent slots for pinned, recent, priority, and individual Codex Micro assignments while preserving native single-host behavior. +- Interleaved pinned Windows and Mac tasks fairly, de-duplicated mirrored task identities, and routed each key to its real owner. +- Defined individual-assignment conflicts: the Stream Deck computer wins a doubly assigned slot, while the other host fills empty slots; duplicate tasks appear only once. +- Normalized additional native `thinking`, `complete`, `completed`, and `done` status names for stable animations and colors. +- Added diagnostics when the two Codex apps use different agent-source modes. +- Fixed explicit release-audit paths and added regression coverage for private runtime-state rejection. +- Kept CDP evaluation promises alive in the renderer to prevent intermittent `Promise was collected` failures on remote agent presses. +- Added a content-free local session-presence catalog so cloud/SSH mirrors are attributed to the computer that owns the rollout even when Codex omits that task from the owner's six native Micro slots. +- Keeps freshly completed owner sessions visible at unread/error priority instead of dropping them behind idle tasks. +- Bounds and validates relay presence catalogs and clears a derived completion state after that task is opened from the deck. + ## 0.6.0 - 2026-07-18 - Added the local macOS Codex Micro launcher and persistent LaunchAgent watcher. diff --git a/README.md b/README.md index d6faf11..335a97b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Other Stream Deck models may work, but the included layout and physical-device t 4. In **Codex Settings > Codex Micro**, choose the agent source, action assignments, joystick actions, and encoder behavior. 5. Build the two Stream Deck pages below. +In Windows + Mac mode, choose the same agent-source mode in both Codex apps when you want both native Pinned lists or both sets of Individual assignments to contribute. Pinned tasks are interleaved fairly. For Individual assignments, the Stream Deck computer wins when both apps assign different tasks to one button, while the other computer fills empty slots. Mirrored copies of the same task are shown only once. See [Multi-host behavior](docs/MULTI_HOST.md#agent-source-modes). + ## Recommended 15-key layout This is the actual polished two-page layout used for the MK.2. It keeps the six live agents on the main page and puts lower-frequency navigation/reasoning controls on page 2. @@ -123,10 +125,10 @@ Do not use the launcher while running untrusted local software. See [SECURITY.md ## Compatibility -Release 0.6.0 was locally validated against: +The current build was locally validated against: -- Codex for Windows `26.715.3651.0` -- Codex for macOS `26.715.31251` (build `5538`) +- Codex for Windows `26.715.4045.0` +- Codex for macOS `26.715.31925` - Stream Deck `7.4.2.22730` - Windows `10.0.26220.0` - Node.js `24.13.0` @@ -136,7 +138,7 @@ The Windows physical-device path and the Windows+Mac relay were exercised on the ## Troubleshooting -Start with [Troubleshooting](docs/TROUBLESHOOTING.md). The important rule is: restart only the Stream Deck plugin/app for plugin updates. Do not restart Codex unless the launcher explicitly says an unbridged Codex generation needs one recovery restart and you choose to proceed. +Start with [Troubleshooting](docs/TROUBLESHOOTING.md). The important rule is: restart only the Stream Deck plugin/app for plugin updates. The macOS watcher never launches a closed Codex app; after a manual app start it permits at most one guarded recovery restart and opens a global cooldown before any later recovery. ## Build and release validation diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7373793..158c493 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,8 +34,12 @@ bundle metadata, reads `CFBundleExecutable`, and launches the app bundle through LaunchServices with the same loopback-only debugging arguments. The per-user LaunchAgent watcher stores a main-process generation (PID, start time, and executable path), reuses healthy bridges, and performs at most one graceful -recovery restart for a later unbridged generation. The generation and recovery -policy is persisted atomically and guarded by a PID-directory lock. +recovery restart for a later stable unbridged generation. It never launches a +closed Codex app. A generation-independent cooldown prevents a failed recovery +from becoming a PID-to-PID restart loop, even if LaunchServices immediately +creates another process. The generation and recovery policy is persisted +atomically and guarded by a PID-directory lock. LaunchAgent stderr is retained +separately from the bounded watcher log for post-crash diagnosis. Both platforms persist a stable `hostId`, `hostName`, and platform identifier. The relay uses that identity; the CDP port is never a relay endpoint. @@ -62,16 +66,22 @@ and routes agent presses by stable `(hostId, threadKey)` identity. Other control target the host selected by the Windows/Mac toggle. Host ownership is resolved from exact local rollout filenames, not from a -renderer's mirrored recent list. This distinguishes a Mac desktop task mirrored -through Windows remote SSH from a genuinely Windows-owned task. File contents, -prompts, responses, and project names are never read. The relay never reads or -proxies the remote CLI app-server stream. +renderer's mirrored recent list. This distinguishes a task's owning desktop +from a stale cloud or remote-SSH mirror. A bounded rollout tail is searched only +for structural activity/completion event tags; prompts, responses, project +names, and other content are neither parsed nor relayed. The relay never reads +or proxies the remote CLI app-server stream. The relay protocol has no arbitrary-evaluation, filesystem, shell, or raw-CDP operation. Payloads are capped at 64 KiB, authentication is required before a snapshot or command is accepted, and command results use request IDs with bounded timeouts. +An authenticated client may remain connected while the Mac app or its native +Micro signals are unavailable. Snapshot failures are caught and rate-limited; +they do not terminate the relay server or watcher. Normal snapshots resume +automatically when the local bridge becomes ready. + ### Rendering Agent keys are original deterministic SVGs generated in memory from task title and state. The status palette is: @@ -108,8 +118,9 @@ In single-host mode Codex Deck has no server, API key, analytics endpoint, or update service. Runtime data stays between Stream Deck, the local plugin process, and the local Codex renderer. Optional multi-host mode adds one user-configured Mac listener reachable through SSH or inside the encrypted -tailnet; titles, task IDs, states, ownership metadata, and typed commands pass -between the paired machines and nowhere else. +tailnet; titles, task IDs, states, a bounded catalog of recent local task UUIDs +and modification times, ownership metadata, and typed commands pass between the +paired machines and nowhere else. ## Compatibility boundary diff --git a/docs/MACOS.md b/docs/MACOS.md index 595a572..4be2899 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -30,7 +30,7 @@ chmod +x start-codex-deck.sh "Start Codex Deck.command" ./start-codex-deck.sh install ``` -`install` copies the watcher runtime into Application Support and installs a per-user LaunchAgent. It does not restart a normal Codex session already open during first installation. A later unbridged Codex generation may receive one graceful recovery restart; the same generation is never restarted repeatedly. +`install` copies the watcher runtime into Application Support and installs a per-user LaunchAgent. It does not restart a normal Codex session already open during first installation and never launches Codex while the app is closed. After you open Codex normally, a later unbridged process must remain stable before it may receive one graceful recovery restart. A global cooldown blocks further automatic recovery across replacement process IDs, preventing restart loops after crashes, power loss, or incomplete app startup. Update by extracting the new launcher and running `install` again. The stable host identity, optional relay configuration, and user-owned icons are preserved. @@ -56,6 +56,7 @@ Update by extracting the new launcher and running `install` again. The stable ho host.json watcher-state.json watcher.log, watcher.log.1 ... + watcher.stderr.log # LaunchAgent/runtime failures icons/ # optional user-owned SVG copies ~/Library/LaunchAgents/com.simeo.codex-deck.watcher.plist @@ -68,6 +69,7 @@ State writes are atomic, a PID-directory lock prevents duplicate watchers, and l ```zsh ./start-codex-deck.sh dry-run tail -n 100 "$HOME/Library/Application Support/CodexDeck/watcher.log" +tail -n 100 "$HOME/Library/Application Support/CodexDeck/watcher.stderr.log" launchctl print "gui/$(id -u)/com.simeo.codex-deck.watcher" plutil -lint "$HOME/Library/LaunchAgents/com.simeo.codex-deck.watcher.plist" ``` diff --git a/docs/MULTI_HOST.md b/docs/MULTI_HOST.md index b15e8d8..901d2d2 100644 --- a/docs/MULTI_HOST.md +++ b/docs/MULTI_HOST.md @@ -7,7 +7,7 @@ Stream Deck -> Windows plugin -> local Windows Codex \-> authenticated relay -> local Mac Codex ``` -The relay never exposes Chrome DevTools. It forwards only typed Codex Deck commands and six native agent snapshots. +The relay never exposes Chrome DevTools. It forwards only typed Codex Deck commands, six native agent snapshots, and a bounded content-free local task-presence catalog. ## Before pairing @@ -40,6 +40,8 @@ On Windows, run the matching configurator and omit `-Token` so the secret is ent The persistent Windows watcher maintains this dedicated tunnel after sign-in and reconnects it after network interruptions. It does not adopt or depend on Codex desktop's remote-CLI SSH process. +The Mac relay can remain authenticated while the Mac Codex app is closed or still loading. Snapshot failures are contained and rate-limited; they do not crash the watcher. Once the native Micro bridge is ready, snapshots resume over the existing connection. + Restart only the Stream Deck plugin or Stream Deck app after pairing. Do not restart Codex. ## Pair with Tailscale @@ -60,17 +62,30 @@ Enter the token in the hidden prompt. The relay accepts loopback, Tailscale IPv4 ## Stream Deck behavior -- The six agent keys are one globally ordered Windows+Mac list. +- The six agent keys form one mode-aware Windows+Mac list controlled by the normal Codex Micro agent-source setting. - Each visible tile receives a small `W` or `M` badge and routes to its owning desktop. - Add **Windows / Mac Target** to page 2. It switches action slots, joystick, encoder, reasoning, standalone keycaps, and New Task between computers. - Agent keys ignore the selected target because each task already knows its owner. - The selected target survives plugin and relay restarts. If Mac is selected while offline, the key visibly fails instead of silently executing on Windows. +### Agent-source modes + +Single-host Windows and macOS setups preserve Codex's six native slots exactly. In multi-host mode, the Codex app on the computer running the Stream Deck plugin is the controller for the combined list: + +- **Pinned:** interleaves the pinned order from Windows and Mac slot by slot, removes duplicates, and routes each task to its owning desktop. With six unique tasks available this gives both hosts up to three keys instead of allowing one full list to hide the other. +- **Recently updated:** globally orders actual Windows and Mac activity. +- **Priority:** ranks approval/questions first, then unread/errors, active work, and idle tasks. +- **Individual assignments:** preserves the controller's slot positions. If both apps assign different tasks to one physical button, the controller assignment wins. When the controller slot is empty and the remote Codex app is also set to Individual assignments, its assignment from the same slot is used. The same task assigned through both apps is shown only once and routed to its rollout owner. + +For a true combined Pinned or Individual list, select that mode in both Codex apps. If the modes differ, the controller mode still determines the list, but only a remote host using the matching mode can contribute its own pinned order or individual assignments. Codex Deck writes a warning to the plugin log when this happens. + +This makes manual mixed layouts possible without another settings application. Assign a synced task directly in the controller Codex app when it is available there. For a Mac-only task that is not selectable on Windows, leave that Windows slot empty and assign the task to the same slot in the Mac Codex Micro settings. Changes are picked up automatically by the next native snapshot. + ### Ownership and SSH mirrors -Codex's built-in remote-SSH feature can mirror a Mac-backed task into the Windows renderer. Codex Deck does not confuse that CLI connection with the Mac desktop app. In multi-host mode it compares exact local rollout **filenames** on both hosts to find the owning desktop; it never reads rollout contents, prompts, responses, or project names. +Codex's built-in remote-SSH feature can mirror a task into the other renderer. Codex Deck does not confuse that CLI connection with the owning desktop. In multi-host mode it compares exact local rollout **filenames** on both hosts and checks only bounded tail text for structural `agent_reasoning`, `function_call`, `turn_context`, and `task_complete` event tags. Prompt text, responses, project names, and other rollout content are neither parsed nor sent through the relay. -For the same cloud task visible on both hosts, live status and selection are merged while commands route to the rollout owner. Ownership is host-generic and contains no hard-coded task IDs or project names. +The relay catalog contains only recent task UUIDs, modification times, and derived `working`, `complete`, or `idle` state. For the same cloud task visible on both hosts, live status and selection are merged while commands route to the rollout owner—even when Codex temporarily omits the task from that owner's six native Micro slots. Ownership is host-generic and contains no hard-coded task IDs or project names. ### Ordering boundary diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 12c14e5..b877387 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -30,6 +30,16 @@ tail -n 100 "$HOME/Library/Application Support/CodexDeck/watcher.log" Do not replace, re-sign, or edit the Codex app bundle. If `start` says an existing normal session needs a restart, it waits for your explicit `yes`. +The installed watcher never launches Codex while it is closed. After you open Codex manually, one controlled recovery restart can occur if the new process lacks the loopback bridge. A global cooldown prevents further automatic restarts across replacement PIDs. + +If an older watcher is repeatedly relaunching Codex after a crash or empty battery, stop only that watcher first: + +```zsh +launchctl bootout "gui/$(id -u)/com.simeo.codex-deck.watcher" +``` + +Then install the launcher from the newest release. This command does not start, stop, or modify Codex itself. + ## Agent keys say Bridge offline - Confirm Codex was started through the launcher. @@ -45,7 +55,7 @@ The native handler was unavailable or the action is not valid in the current com ## Agent assignments are unexpected -Codex Deck does not choose the six native tasks. Open **Codex Settings > Codex Micro > Agent keys** and select pinned, recently updated, priority, or custom assignments. In multi-host mode, both native six-slot lists are de-duplicated and globally ordered; mirrored tasks route to the host owning the exact local rollout filename. +Codex Deck does not choose the six native tasks. Open **Codex Settings > Codex Micro > Agent keys** and select pinned, recently updated, priority, or custom assignments. For combined Pinned or Individual assignments, select the same mode in both Codex apps. Pinned tasks are interleaved between hosts; in Individual mode the Stream Deck computer wins a conflicting slot and the remote host fills empty slots. Both lists are de-duplicated, and mirrored tasks route to the host owning the exact local rollout filename. ## Local command icon does not appear @@ -63,6 +73,7 @@ Restart Stream Deck. Elgato notes that plugins can fail to appear when the Strea - First confirm both local bridges work independently. - SSH mode: confirm the Windows watcher is installed and the SSH alias works outside Codex's remote-CLI connection. - Inspect `%LOCALAPPDATA%\CodexDeck\watcher.log` for the dedicated relay tunnel state. +- On macOS, inspect both `watcher.log` and `watcher.stderr.log` under `~/Library/Application Support/CodexDeck/`. - Confirm the Windows relay URL is `ws://127.0.0.1:` for SSH, or an explicit Tailscale address. - Restart only the Stream Deck plugin/app after configuration. Do not restart Codex. - Run `Configure-CodexDeckRelay.ps1 -Disable` to return cleanly to Windows-only mode. diff --git a/launcher/macos/codex-deck-macos.ts b/launcher/macos/codex-deck-macos.ts index 0554120..bbe4d77 100644 --- a/launcher/macos/codex-deck-macos.ts +++ b/launcher/macos/codex-deck-macos.ts @@ -25,6 +25,7 @@ const BRIDGE_STATE_PATH = join(STATE_ROOT, "codex-micro-bridge.json"); const HOST_STATE_PATH = join(STATE_ROOT, "host.json"); const WATCHER_STATE_PATH = join(STATE_ROOT, "watcher-state.json"); const WATCHER_LOG_PATH = join(STATE_ROOT, "watcher.log"); +const WATCHER_STDERR_PATH = join(STATE_ROOT, "watcher.stderr.log"); const WATCHER_LOCK_PATH = join(STATE_ROOT, "watcher.lock"); const RELAY_SERVER_CONFIG_PATH = join(STATE_ROOT, "relay-server.json"); const INSTALLED_RUNTIME_PATH = join(STATE_ROOT, "codex-deck-macos.mjs"); @@ -340,6 +341,12 @@ async function log(message: string): Promise { finally { await file.close(); } } +function safeLog(message: string): void { + void log(message).catch((error) => { + console.error(`${new Date().toISOString()} [${process.pid}] Watcher logging failed: ${String(error)}`); + }); +} + export async function acquirePidLock(lockPath = WATCHER_LOCK_PATH): Promise<(() => Promise) | null> { await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 }); try { @@ -376,8 +383,9 @@ async function runWatcher(): Promise { relayControl?.close(); if (!released) { released = true; await release(); } }; - process.once("SIGTERM", () => { void cleanup().finally(() => process.exit(0)); }); - process.once("SIGINT", () => { void cleanup().finally(() => process.exit(0)); }); + process.once("SIGTERM", () => { safeLog("Watcher received SIGTERM."); void cleanup().finally(() => process.exit(0)); }); + process.once("SIGINT", () => { safeLog("Watcher received SIGINT."); void cleanup().finally(() => process.exit(0)); }); + process.on("unhandledRejection", (reason) => { safeLog(`Unhandled watcher rejection: ${String(reason)}`); }); await log("Watcher started."); let policy = resumeWatcherPolicyState(await readJson(WATCHER_STATE_PATH)); @@ -404,12 +412,12 @@ async function runWatcher(): Promise { relaySignature = ""; if (relayConfig?.enabled) { const identity = await hostState(); - relayControl = new CodexMicroRendererBridge((message) => { void log(message); }); + relayControl = new CodexMicroRendererBridge(safeLog); relayServer = new CodexRelayServer( relayConfig, { ...identity, platform: "darwin" }, relayControl, - (message) => { void log(message); } + safeLog ); await relayServer.start(); } @@ -426,11 +434,7 @@ async function runWatcher(): Promise { } else { enabledSignature = ""; await removeStaleBridgeState(null, log); - if (decision.action.type === "launch-bridge") { - const selectedPort = await chooseLoopbackPort(); - await log(`Starting Codex with a loopback bridge (${decision.action.reason}).`); - await launchCodex(installation, selectedPort); - } else if (decision.action.type === "restart-for-recovery" && main) { + if (decision.action.type === "restart-for-recovery" && main) { await log(`Recovering Codex bridge once for generation ${main.generation} (${decision.action.reason}).`); await terminateCodex(main); const refreshed = await discoverCodexInstallation(); @@ -499,7 +503,7 @@ export function buildLaunchAgentPlist(watcherLauncherPath = WATCHER_LAUNCHER_PAT StandardOutPath /dev/null StandardErrorPath - /dev/null + ${xml(WATCHER_STDERR_PATH)} `; @@ -539,7 +543,7 @@ async function installLaunchAgent(): Promise { async function uninstallLaunchAgent(): Promise { run("/bin/launchctl", ["bootout", `gui/${currentUserId()}`, LAUNCH_AGENT_PATH], { allowFailure: true }); await rm(LAUNCH_AGENT_PATH, { force: true }); - for (const path of [INSTALLED_RUNTIME_PATH, WATCHER_LAUNCHER_PATH, BRIDGE_STATE_PATH, WATCHER_STATE_PATH, WATCHER_LOG_PATH, `${WATCHER_LOG_PATH}.1`, `${WATCHER_LOG_PATH}.2`, `${WATCHER_LOG_PATH}.3`]) { + for (const path of [INSTALLED_RUNTIME_PATH, WATCHER_LAUNCHER_PATH, BRIDGE_STATE_PATH, WATCHER_STATE_PATH, WATCHER_LOG_PATH, `${WATCHER_LOG_PATH}.1`, `${WATCHER_LOG_PATH}.2`, `${WATCHER_LOG_PATH}.3`, WATCHER_STDERR_PATH]) { await rm(path, { force: true }); } await rm(WATCHER_LOCK_PATH, { recursive: true, force: true }); @@ -619,22 +623,28 @@ async function selfTest(): Promise { assert.equal(result.action.type, "preserve-initial-session", "same initial process is never restarted"); result = evaluateWatcherPolicy(state, { now: 10_001, generation: "B", bridgeHealthy: false }); - assert.equal(result.action.type, "restart-for-recovery", "rapid main-process replacement is detected"); + assert.equal(result.action.type, "wait", "a new process must remain stable before recovery"); + state = result.state; + result = evaluateWatcherPolicy(state, { now: 30_001, generation: "B", bridgeHealthy: false }); + assert.equal(result.action.type, "restart-for-recovery", "a stable new process can be recovered once"); state = result.state; - result = evaluateWatcherPolicy(state, { now: 41_000, generation: "B", bridgeHealthy: false }); - assert.equal(result.action.type, "wait", "the same generation is not restarted repeatedly"); + result = evaluateWatcherPolicy(state, { now: 61_000, generation: "C", bridgeHealthy: false }); + assert.equal(result.action.type, "wait", "the global circuit breaker blocks a new-generation restart loop"); state = createWatcherPolicyState(0); result = evaluateWatcherPolicy(state, { now: 0, generation: null, bridgeHealthy: false }); state = result.state; result = evaluateWatcherPolicy(state, { now: 8_000, generation: null, bridgeHealthy: false }); - assert.equal(result.action.type, "launch-bridge", "an observed stopped interval triggers recovery"); + assert.equal(result.action.type, "wait", "the watcher never launches Codex while it is closed"); state = createWatcherPolicyState(0); result = evaluateWatcherPolicy(state, { now: 0, generation: "A", bridgeHealthy: true }); state = result.state; result = evaluateWatcherPolicy(state, { now: 10_000, generation: "B", bridgeHealthy: false }); - assert.equal(result.action.type, "restart-for-recovery", "a previous healthy bridge recovers after replacement/update"); + assert.equal(result.action.type, "wait", "a replacement process must remain stable before recovery"); + state = result.state; + result = evaluateWatcherPolicy(state, { now: 30_000, generation: "B", bridgeHealthy: false }); + assert.equal(result.action.type, "restart-for-recovery", "a previous healthy bridge recovers after a stable replacement"); state = createWatcherPolicyState(0); result = evaluateWatcherPolicy(state, { now: 0, generation: null, bridgeHealthy: false }); @@ -660,7 +670,7 @@ async function selfTest(): Promise { assert.equal(isBridgeStateStale(70_000, null), true, "stale/invalid port state is rejected"); assert.equal(isBridgeStateStale(43123, 43123), false, "the active bridge state is retained"); - console.log("macOS self-test passed: 8 recovery, race, stale-state, and single-instance scenarios."); + console.log("macOS self-test passed: safe recovery, circuit-breaker, race, stale-state, and single-instance scenarios."); } async function main(): Promise { diff --git a/launcher/macos/watcher-policy.ts b/launcher/macos/watcher-policy.ts index d8a3a71..be848f9 100644 --- a/launcher/macos/watcher-policy.ts +++ b/launcher/macos/watcher-policy.ts @@ -8,7 +8,6 @@ export type WatcherAction = | { type: "preserve-initial-session" } | { type: "reuse-bridge" } | { type: "wait"; reason: string } - | { type: "launch-bridge"; reason: string } | { type: "restart-for-recovery"; generation: string; reason: string }; export type WatcherPolicyState = { @@ -19,12 +18,16 @@ export type WatcherPolicyState = { stoppedSince: number | null; hadHealthyBridge: boolean; recoveryPendingUntil: number; + recoveryCooldownUntil: number; + unbridgedGeneration: string | null; + unbridgedSince: number | null; recoveryAttempts: string[]; }; -export const DEFAULT_STARTUP_GRACE_MS = 5_000; -export const DEFAULT_STOPPED_GRACE_MS = 2_000; +export const DEFAULT_STARTUP_GRACE_MS = 30_000; +export const DEFAULT_UNBRIDGED_STABLE_MS = 10_000; export const DEFAULT_RECOVERY_STARTUP_MS = 30_000; +export const DEFAULT_RECOVERY_COOLDOWN_MS = 10 * 60_000; export function createWatcherPolicyState(now = Date.now()): WatcherPolicyState { return { @@ -35,6 +38,9 @@ export function createWatcherPolicyState(now = Date.now()): WatcherPolicyState { stoppedSince: null, hadHealthyBridge: false, recoveryPendingUntil: 0, + recoveryCooldownUntil: 0, + unbridgedGeneration: null, + unbridgedSince: null, recoveryAttempts: [] }; } @@ -48,8 +54,11 @@ export function resumeWatcherPolicyState( ...stored, startupGraceUntil: now + DEFAULT_STARTUP_GRACE_MS, stoppedSince: null, - recoveryPendingUntil: 0, - recoveryAttempts: [...stored.recoveryAttempts].slice(-16) + recoveryPendingUntil: Math.max(Number(stored.recoveryPendingUntil) || 0, now + DEFAULT_STARTUP_GRACE_MS), + recoveryCooldownUntil: Number(stored.recoveryCooldownUntil) || 0, + unbridgedGeneration: null, + unbridgedSince: null, + recoveryAttempts: [...(stored.recoveryAttempts ?? [])].slice(-16) }; } @@ -81,18 +90,11 @@ export function evaluateWatcherPolicy( if (generation == null) { if (next.stoppedSince == null) next.stoppedSince = now; - if (now < next.recoveryPendingUntil) { - return { state: next, action: { type: "wait", reason: "bridge-startup-pending" } }; - } - if (now < next.startupGraceUntil) { - return { state: next, action: { type: "wait", reason: "launch-agent-startup-grace" } }; - } - if (now - next.stoppedSince < DEFAULT_STOPPED_GRACE_MS) { - return { state: next, action: { type: "wait", reason: "confirm-stopped-interval" } }; - } - next.recoveryPendingUntil = now + DEFAULT_RECOVERY_STARTUP_MS; - next.stoppedSince = now; - return { state: next, action: { type: "launch-bridge", reason: "observed-stopped-interval" } }; + next.lastGeneration = null; + next.suppressedInitialGeneration = null; + next.unbridgedGeneration = null; + next.unbridgedSince = null; + return { state: next, action: { type: "wait", reason: "codex-not-running" } }; } const previousGeneration = next.lastGeneration; @@ -105,9 +107,16 @@ export function evaluateWatcherPolicy( next.hadHealthyBridge = true; next.recoveryPendingUntil = 0; next.suppressedInitialGeneration = null; + next.unbridgedGeneration = null; + next.unbridgedSince = null; return { state: next, action: { type: "reuse-bridge" } }; } + if (next.unbridgedGeneration !== generation) { + next.unbridgedGeneration = generation; + next.unbridgedSince = now; + } + if (now < next.recoveryPendingUntil) { return { state: next, action: { type: "wait", reason: "bridge-startup-pending" } }; } @@ -125,10 +134,18 @@ export function evaluateWatcherPolicy( return { state: next, action: { type: "preserve-initial-session" } }; } + if (now < next.recoveryCooldownUntil) { + return { state: next, action: { type: "wait", reason: "automatic-recovery-circuit-open" } }; + } + if (next.recoveryAttempts.includes(generation)) { return { state: next, action: { type: "wait", reason: "recovery-already-attempted-for-generation" } }; } + if (next.unbridgedSince == null || now - next.unbridgedSince < DEFAULT_UNBRIDGED_STABLE_MS) { + return { state: next, action: { type: "wait", reason: "confirm-stable-unbridged-generation" } }; + } + const shouldRecover = generationChanged || observedStoppedInterval || next.hadHealthyBridge || now >= next.startupGraceUntil; if (!shouldRecover) { return { state: next, action: { type: "wait", reason: "launch-agent-startup-grace" } }; @@ -136,6 +153,9 @@ export function evaluateWatcherPolicy( next.recoveryAttempts = [...next.recoveryAttempts.slice(-15), generation]; next.recoveryPendingUntil = now + DEFAULT_RECOVERY_STARTUP_MS; + next.recoveryCooldownUntil = now + DEFAULT_RECOVERY_COOLDOWN_MS; + next.unbridgedGeneration = null; + next.unbridgedSince = null; const reason = generationChanged ? "main-process-generation-changed" : observedStoppedInterval diff --git a/launcher/runtime-override.ts b/launcher/runtime-override.ts index c38316d..435d7fc 100644 --- a/launcher/runtime-override.ts +++ b/launcher/runtime-override.ts @@ -142,6 +142,23 @@ export function buildRuntimeVerificationExpression(): string { type DebugTarget = { type?: string; url?: string; webSocketDebuggerUrl?: string }; type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void }; +export function selectRuntimeTarget(targets: DebugTarget[]): DebugTarget | undefined { + const pages = targets.filter((target) => + target.type === "page" && target.webSocketDebuggerUrl && target.url?.startsWith("app://") + ); + const isIndexDocument = (target: DebugTarget): boolean => { + try { return new URL(target.url!).pathname === "/index.html"; } + catch { return false; } + }; + const isAuxiliarySurface = (target: DebugTarget): boolean => + /avatar-overlay|composition-surface/i.test(target.url ?? ""); + + return pages.find((target) => isIndexDocument(target) && !new URL(target.url!).search) + ?? pages.find(isIndexDocument) + ?? pages.find((target) => !isAuxiliarySurface(target) && !target.url?.includes("initialRoute=")) + ?? pages.find((target) => !isAuxiliarySurface(target)); +} + class CdpClient { private readonly socket: WebSocket; private nextId = 0; @@ -189,8 +206,7 @@ async function findTarget(port: number, timeout = 20_000): Promise const response = await fetch(`http://127.0.0.1:${port}/json/list`); if (response.ok) { const targets = await response.json() as DebugTarget[]; - const pages = targets.filter((target) => target.type === "page" && target.webSocketDebuggerUrl && target.url?.startsWith("app://")); - const target = pages.find((page) => !page.url?.includes("initialRoute=")) ?? pages[0]; + const target = selectRuntimeTarget(targets); if (target) return target; } } catch { /* Codex is still starting. */ } diff --git a/package-lock.json b/package-lock.json index 88854dd..985367a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-stream-deck", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-stream-deck", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@elgato/streamdeck": "2.1.0", diff --git a/package.json b/package.json index 312c949..368e2e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-stream-deck", - "version": "0.6.0", + "version": "0.6.1", "private": false, "type": "module", "description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay", diff --git a/scripts/audit-release.mjs b/scripts/audit-release.mjs index 1235feb..c1fa6d0 100644 --- a/scripts/audit-release.mjs +++ b/scripts/audit-release.mjs @@ -2,7 +2,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import { basename, extname, resolve } from "node:path"; const roots = process.argv.slice(2).length - ? process.argv.slice(2).map(resolve) + ? process.argv.slice(2).map((path) => resolve(path)) : [resolve("dist/com.simeo.codex-deck.sdPlugin"), resolve("release/codex-deck-launcher"), resolve("release/codex-deck-launcher-macos")]; const forbiddenFiles = new Set([ diff --git a/scripts/relay-smoke.ts b/scripts/relay-smoke.ts index 28d3c96..a707b86 100644 --- a/scripts/relay-smoke.ts +++ b/scripts/relay-smoke.ts @@ -15,6 +15,7 @@ const snapshot = await new Promise((resolve, reject) => { }); console.log(`Relay snapshot: ${snapshot.host.hostName} (${snapshot.host.platform}, ${snapshot.host.hostId})`); +console.log(`Agent source: ${snapshot.snapshot.agentSource}`); for (const slot of snapshot.snapshot.slots) { console.log(`${slot.id + 1}: ${slot.status}${slot.selected ? " selected" : ""} | local-rollout=${slot.ownedByHost === true ? "yes" : "no"} | activity=${slot.activityAt ?? "unknown"} | ${slot.threadKey ?? "empty"} | ${slot.title ?? ""}`); } diff --git a/src/codex-micro-renderer-bridge.ts b/src/codex-micro-renderer-bridge.ts index 6c64352..30e30d6 100644 --- a/src/codex-micro-renderer-bridge.ts +++ b/src/codex-micro-renderer-bridge.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -14,6 +15,23 @@ type DebugTarget = { webSocketDebuggerUrl?: string; }; +export function selectCodexMainTarget(targets: DebugTarget[]): DebugTarget | undefined { + const candidates = targets.filter((target) => + target.type === "page" && target.webSocketDebuggerUrl && target.url.startsWith("app://") + ); + const isIndexDocument = (target: DebugTarget): boolean => { + try { return new URL(target.url).pathname === "/index.html"; } + catch { return false; } + }; + const isAuxiliarySurface = (target: DebugTarget): boolean => + /avatar-overlay|composition-surface/i.test(target.url); + + return candidates.find((target) => isIndexDocument(target) && !new URL(target.url).search) + ?? candidates.find(isIndexDocument) + ?? candidates.find((target) => !isAuxiliarySurface(target) && !target.url.includes("initialRoute=")) + ?? candidates.find((target) => !isAuxiliarySurface(target)); +} + type CdpResponse = { id?: number; result?: { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string; exception?: { description?: string } } }; @@ -108,26 +126,56 @@ const SNAPSHOT_EXPRESSION = `(async () => { let layout = definitions.layout.default; let agentSource = definitions.agentSource.default; let lightingAutoOff = definitions.lightingAutoOff?.default ?? '3-minutes'; - const settingReaders = exportedValues.filter((candidate) => { - if (typeof candidate !== 'function' || candidate.length !== 2) return false; + + let settingsResolved = false; + const directSettingReader = exportedValues.find((candidate) => { + if (typeof candidate !== 'function' || candidate.length !== 1) return false; const source = Function.prototype.toString.call(candidate); - return source.includes('.key') && source.includes('.default'); + return source.includes('get-setting') && source.includes('.default'); }); - for (const readSetting of settingReaders) { + if (directSettingReader) { try { - const candidateLayout = await readSetting(found.node.store.get, definitions.layout); - const candidateAgentSource = await readSetting(found.node.store.get, definitions.agentSource); + const candidateLayout = await directSettingReader(definitions.layout); + const candidateAgentSource = await directSettingReader(definitions.agentSource); const candidateLightingAutoOff = definitions.lightingAutoOff - ? await readSetting(found.node.store.get, definitions.lightingAutoOff) + ? await directSettingReader(definitions.lightingAutoOff) : lightingAutoOff; - if (candidateLayout?.version !== 1 || typeof candidateLayout.slots !== 'object') continue; - if (!['pinned', 'recent', 'priority', 'custom'].includes(candidateAgentSource)) continue; - layout = candidateLayout; - agentSource = candidateAgentSource; - if (typeof candidateLightingAutoOff === 'string') lightingAutoOff = candidateLightingAutoOff; - break; + if ( + candidateLayout?.version === 1 && + typeof candidateLayout.slots === 'object' && + ['pinned', 'recent', 'priority', 'custom'].includes(candidateAgentSource) + ) { + layout = candidateLayout; + agentSource = candidateAgentSource; + if (typeof candidateLightingAutoOff === 'string') lightingAutoOff = candidateLightingAutoOff; + settingsResolved = true; + } } catch {} } + + if (!settingsResolved) { + const settingReaders = exportedValues.filter((candidate) => { + if (typeof candidate !== 'function' || candidate.length !== 2) return false; + const source = Function.prototype.toString.call(candidate); + return source.includes('.key') && source.includes('.default'); + }); + const getStoreValue = found.node.store.get.bind(found.node.store); + for (const readSetting of settingReaders) { + try { + const candidateLayout = await readSetting(getStoreValue, definitions.layout); + const candidateAgentSource = await readSetting(getStoreValue, definitions.agentSource); + const candidateLightingAutoOff = definitions.lightingAutoOff + ? await readSetting(getStoreValue, definitions.lightingAutoOff) + : lightingAutoOff; + if (candidateLayout?.version !== 1 || typeof candidateLayout.slots !== 'object') continue; + if (!['pinned', 'recent', 'priority', 'custom'].includes(candidateAgentSource)) continue; + layout = candidateLayout; + agentSource = candidateAgentSource; + if (typeof candidateLightingAutoOff === 'string') lightingAutoOff = candidateLightingAutoOff; + break; + } catch {} + } + } const toEpoch = (value) => { if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value < 100000000000 ? value * 1000 : value; if (typeof value === 'string') { @@ -177,6 +225,7 @@ export class CodexMicroRendererBridge { private connecting?: Promise; private lastSnapshot?: MicroSnapshot; private readonly sessionOwnership = new CodexSessionOwnershipIndex(); + private readonly evaluationNamespace = randomUUID(); constructor(private readonly log: (message: string) => void) {} @@ -204,6 +253,7 @@ export class CodexMicroRendererBridge { await this.dispatch("codex-micro-hid-event", { event: { key: `AG0${slot}`, act, slot, threadKey } }, "codex-micro-hid-event"); + if (act === 1 && threadKey) this.sessionOwnership.markOpened(threadKey); } async sendAction(slot: MicroActionSlot, act: 0 | 1): Promise { @@ -346,8 +396,7 @@ export class CodexMicroRendererBridge { private async connect(): Promise { const port = await discoverDebugPort(); const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`); - const candidates = targets.filter((target) => target.type === "page" && target.webSocketDebuggerUrl && target.url.startsWith("app://")); - const target = candidates.find((item) => !item.url.includes("initialRoute=")) ?? candidates[0]; + const target = selectCodexMainTarget(targets); if (!target?.webSocketDebuggerUrl) throw new Error("Kein Codex-Hauptfenster mit Debug-Brücke gefunden."); const socket = new WebSocket(target.webSocketDebuggerUrl); @@ -367,6 +416,10 @@ export class CodexMicroRendererBridge { const socket = this.socket; if (!socket || socket.readyState !== WebSocket.OPEN) return Promise.reject(new Error("Codex-Micro-Brücke ist nicht verbunden.")); const id = ++this.nextId; + // CDP may garbage-collect an awaited Runtime.evaluate promise while a + // renderer handler or dynamic import is still pending. Keep the exact + // promise reachable from the renderer until after our own timeout. + const retainedExpression = retainEvaluationPromise(expression, `${this.evaluationNamespace}-${id}`); return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); @@ -382,7 +435,7 @@ export class CodexMicroRendererBridge { resolve(result?.result?.value as T); } }); - socket.send(JSON.stringify({ id, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } })); + socket.send(JSON.stringify({ id, method: "Runtime.evaluate", params: { expression: retainedExpression, awaitPromise: true, returnByValue: true } })); }); } @@ -411,6 +464,17 @@ export class CodexMicroRendererBridge { } } +export function retainEvaluationPromise(expression: string, id: string | number): string { + const key = `codex-deck-${id}`; + return `(() => { + const store = globalThis.__codexDeckPendingEvaluations ??= new Map(); + const pending = Promise.resolve((${expression})); + store.set(${JSON.stringify(key)}, pending); + setTimeout(() => store.delete(${JSON.stringify(key)}), 10000); + return pending; + })()`; +} + async function discoverDebugPort(): Promise { const fromFile = await readPortFile(); if (fromFile && await isDebugPort(fromFile)) return fromFile; diff --git a/src/codex-relay-server.ts b/src/codex-relay-server.ts index d3c7c9a..6126d89 100644 --- a/src/codex-relay-server.ts +++ b/src/codex-relay-server.ts @@ -24,6 +24,8 @@ export class CodexRelayServer { private poll?: NodeJS.Timeout; private snapshotInFlight?: Promise; private readonly authenticated = new Set(); + private lastSnapshotError = ""; + private lastSnapshotErrorAt = 0; constructor( private readonly config: RelayServerConfig, @@ -74,10 +76,12 @@ export class CodexRelayServer { } this.authenticated.add(socket); socket.send(JSON.stringify({ type: "ready", protocol: RELAY_PROTOCOL_VERSION, host: this.host })); - socket.on("message", (message) => void this.handleMessage(socket, message.toString())); + socket.on("message", (message) => { + void this.handleMessage(socket, message.toString()).catch((error) => this.reportSnapshotError(error)); + }); socket.on("close", () => this.authenticated.delete(socket)); socket.on("error", () => this.authenticated.delete(socket)); - void this.publishSnapshot(socket); + void this.publishSnapshot(socket).catch((error) => this.reportSnapshotError(error)); }); socket.on("close", () => clearTimeout(authTimer)); } @@ -109,13 +113,24 @@ export class CodexRelayServer { if (!this.server) return; this.poll = setTimeout(async () => { try { if (this.authenticated.size) await this.publishSnapshot(); } - catch (error) { this.log(`Relay snapshot failed: ${String(error)}`); } + catch (error) { this.reportSnapshotError(error); } finally { this.scheduleSnapshot(); } }, delay); } + private reportSnapshotError(error: unknown): void { + const message = String(error); + const now = Date.now(); + if (message === this.lastSnapshotError && now - this.lastSnapshotErrorAt < 60_000) return; + this.lastSnapshotError = message; + this.lastSnapshotErrorAt = now; + this.log(`Relay snapshot unavailable: ${message}`); + } + private async publishSnapshot(only?: WebSocket): Promise { const message = await this.currentSnapshotMessage(); + this.lastSnapshotError = ""; + this.lastSnapshotErrorAt = 0; const encoded = JSON.stringify(message); for (const socket of only ? [only] : this.authenticated) { if (socket.readyState === WebSocket.OPEN) socket.send(encoded); diff --git a/src/controller.ts b/src/controller.ts index 0c26912..c83937b 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -49,6 +49,7 @@ export class DeckController { private lastAssignmentSignature = ""; private lastStatusSignature = ""; private lastLayoutSignature = ""; + private lastAgentSourceSignature = ""; async start(): Promise { this.stopped = false; @@ -189,7 +190,15 @@ export class DeckController { if (this.localHost && this.targetPlatform !== this.localHost.platform && remoteSnapshot) this.targetHostId = remoteSnapshot.host.hostId; else if (this.localHost && this.targetPlatform === this.localHost.platform) this.targetHostId = this.localHost.hostId; const inputs = [this.localSnapshot, remoteSnapshot].filter((value): value is HostSnapshot => value != null); - this.routedSlots = this.activityIndex.merge(inputs); + const agentSources = inputs.map((input) => `${input.host.platform}=${input.snapshot.agentSource}`); + const agentSourceSignature = agentSources.join(","); + if (agentSourceSignature !== this.lastAgentSourceSignature) { + this.lastAgentSourceSignature = agentSourceSignature; + if (new Set(inputs.map((input) => input.snapshot.agentSource)).size > 1) { + streamDeck.logger.warn(`Codex agent sources differ (${agentSources.join(" ")}). The Windows controller mode determines the combined list; Pinned and Individual assignments merge only hosts using that mode.`); + } + } + this.routedSlots = this.activityIndex.merge(inputs, Date.now(), this.localHost?.hostId); const assignments = this.routedSlots.map((slot) => `${slot.id}=${slot.host.platform}:${slot.threadKey ?? "empty"}`).join(" "); if (assignments !== this.lastAssignmentSignature) { diff --git a/src/relay-protocol.ts b/src/relay-protocol.ts index 3070a93..c52006c 100644 --- a/src/relay-protocol.ts +++ b/src/relay-protocol.ts @@ -1,6 +1,6 @@ import { OFFICIAL_KEYCAP_IDS, type OfficialKeycapId } from "./keycaps.js"; import type { - CodexHost, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, RoutedAgentSlot + CodexHost, HostSessionPresence, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, RoutedAgentSlot } from "./types.js"; export const RELAY_PROTOCOL_VERSION = 1; @@ -29,16 +29,17 @@ export type RelayServerMessage = RelayReadyMessage | RelaySnapshotMessage | Rela export type HostSnapshot = { host: CodexHost; snapshot: MicroSnapshot; observedAt: number }; type ActivityRecord = { activityAt: number; signature: string; lastSeenAt: number }; +type SessionOwner = { input: HostSnapshot; session: HostSessionPresence }; export class HostActivityIndex { private readonly activity = new Map(); - merge(inputs: HostSnapshot[], now = Date.now()): RoutedAgentSlot[] { + merge(inputs: HostSnapshot[], now = Date.now(), authoritativeHostId?: string): RoutedAgentSlot[] { const routed: RoutedAgentSlot[] = []; for (const input of inputs) { for (const slot of input.snapshot.slots) { if (!slot.threadKey) continue; - const key = `${input.host.hostId}:${slot.threadKey}`; + const key = `${input.host.hostId}:${threadIdentity(slot.threadKey)}`; const signature = `${slot.status}:${slot.selected}:${slot.title ?? ""}`; const prior = this.activity.get(key); const explicit = validTimestamp(slot.activityAt); @@ -57,19 +58,105 @@ export class HostActivityIndex { for (const [key, value] of this.activity) { if (now - value.lastSeenAt > 86_400_000) this.activity.delete(key); } + if (inputs.length === 0) return []; + if (inputs.length === 1) return nativeSlotOrder(inputs[0]!, routed); + const mirrors = new Map(); for (const slot of routed) { - const candidates = mirrors.get(slot.threadKey!) ?? []; + const identity = threadIdentity(slot.threadKey!); + const candidates = mirrors.get(identity) ?? []; candidates.push(slot); - mirrors.set(slot.threadKey!, candidates); + mirrors.set(identity, candidates); } - return [...mirrors.values()].map(mergeMirrors) - .sort(compareActivity) + const sessionOwners = sessionOwnerIndex(inputs); + const merged = [...mirrors.entries()].map(([identity, candidates]) => mergeMirrors(candidates, sessionOwners.get(identity))); + const byThread = new Map(merged.map((slot) => [threadIdentity(slot.threadKey!), slot])); + const authority = inputs.find((input) => input.host.hostId === authoritativeHostId) ?? inputs[0]!; + + if (authority.snapshot.agentSource === "pinned") return pinnedSlotOrder(authority, inputs, byThread); + if (authority.snapshot.agentSource === "custom") return customSlotOrder(authority, inputs, byThread); + return merged + .sort(authority.snapshot.agentSource === "priority" ? comparePriority : compareActivity) .slice(0, 6) .map((slot, id) => ({ ...slot, id })); } } +function nativeSlotOrder(input: HostSnapshot, routed: RoutedAgentSlot[]): RoutedAgentSlot[] { + const bySourceSlot = new Map( + routed.filter((candidate) => candidate.host.hostId === input.host.hostId) + .map((candidate) => [candidate.sourceSlot, candidate]) + ); + return input.snapshot.slots.map((slot, id) => { + const candidate = bySourceSlot.get(slot.id); + return candidate ? { ...candidate, id } : emptyRoutedSlot(input, slot, id); + }); +} + +function pinnedSlotOrder( + authority: HostSnapshot, + inputs: HostSnapshot[], + byThread: Map +): RoutedAgentSlot[] { + const sources = [ + authority, + ...inputs.filter((input) => input.host.hostId !== authority.host.hostId && input.snapshot.agentSource === "pinned") + ]; + const result: RoutedAgentSlot[] = []; + const used = new Set(); + for (let sourceSlot = 0; sourceSlot < 6 && result.length < 6; sourceSlot += 1) { + for (const source of sources) { + const slot = source.snapshot.slots[sourceSlot]; + if (!slot?.threadKey) continue; + const identity = threadIdentity(slot.threadKey); + if (used.has(identity)) continue; + used.add(identity); + const routed = byThread.get(identity); + if (routed) result.push({ ...routed, id: result.length }); + if (result.length === 6) break; + } + } + while (result.length < 6) result.push(emptyRoutedPosition(authority, result.length)); + return result; +} + +function customSlotOrder( + authority: HostSnapshot, + inputs: HostSnapshot[], + byThread: Map +): RoutedAgentSlot[] { + const remoteSources = inputs.filter((input) => + input.host.hostId !== authority.host.hostId && input.snapshot.agentSource === "custom" + ); + const used = new Set(); + return authority.snapshot.slots.map((localSlot, id) => { + const candidates = [ + { source: authority, slot: localSlot }, + ...remoteSources.map((source) => ({ source, slot: source.snapshot.slots[id]! })) + ]; + for (const candidate of candidates) { + if (!candidate.slot?.threadKey) continue; + const identity = threadIdentity(candidate.slot.threadKey); + if (used.has(identity)) continue; + used.add(identity); + const routed = byThread.get(identity); + return routed ? { ...routed, id } : emptyRoutedSlot(candidate.source, candidate.slot, id); + } + return emptyRoutedPosition(authority, id); + }); +} + +function emptyRoutedSlot(input: HostSnapshot, slot: MicroSnapshot["slots"][number], id: number): RoutedAgentSlot { + return { ...slot, id, host: input.host, sourceSlot: slot.id, observedAt: input.observedAt }; +} + +function emptyRoutedPosition(input: HostSnapshot, id: number): RoutedAgentSlot { + return { + id, threadKey: null, title: null, status: "off", selected: false, + host: input.host, sourceSlot: id, observedAt: input.observedAt + }; +} + 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; @@ -95,7 +182,12 @@ export function parseRelayCommand(value: unknown): RelayCommand | null { function isSnapshot(value: unknown): value is MicroSnapshot { if (!isRecord(value) || !Array.isArray(value.slots) || value.slots.length !== 6 || !isRecord(value.layout)) return false; - return value.slots.every((slot, index) => isRecord(slot) && slot.id === index && typeof slot.status === "string"); + if (!value.slots.every((slot, index) => isRecord(slot) && slot.id === index && typeof slot.status === "string")) return false; + if (value.hostSessions == null) return true; + return Array.isArray(value.hostSessions) && value.hostSessions.length <= 128 && value.hostSessions.every((session) => + isRecord(session) && isThreadKey(session.threadId) && validTimestamp(session.activityAt) != null && + ["idle", "working", "complete"].includes(String(session.status)) + ); } function isHost(value: unknown): value is CodexHost { @@ -123,10 +215,14 @@ function compareOwnership(left: RoutedAgentSlot, right: RoutedAgentSlot): number return compareActivity(left, right); } -function mergeMirrors(candidates: RoutedAgentSlot[]): RoutedAgentSlot { +function mergeMirrors(candidates: RoutedAgentSlot[], sessionOwner?: SessionOwner): RoutedAgentSlot { let owner = candidates[0]!; - for (const candidate of candidates.slice(1)) { - if (compareOwnership(candidate, owner) < 0) owner = candidate; + const explicitOwner = sessionOwner && candidates.find((candidate) => candidate.host.hostId === sessionOwner.input.host.hostId); + if (explicitOwner) owner = explicitOwner; + else { + for (const candidate of candidates.slice(1)) { + if (compareOwnership(candidate, owner) < 0) owner = candidate; + } } const strongest = [...candidates].sort((left, right) => mirrorStatusPriority(right.status) - mirrorStatusPriority(left.status) || @@ -134,19 +230,38 @@ function mergeMirrors(candidates: RoutedAgentSlot[]): RoutedAgentSlot { )[0]!; const ownedCandidates = candidates.filter((candidate) => candidate.ownedByHost === true); const recencyCandidates = ownedCandidates.length ? ownedCandidates : candidates; + const sessionStatus = sessionOwner?.session.status; + const status = sessionStatus && sessionStatus !== "idle" && !["approval", "awaiting-approval", "awaiting-response", "unread", "error"].includes(strongest.status) + ? sessionStatus + : strongest.status; + const routedOwner = sessionOwner?.input.host ?? owner.host; return { ...owner, - status: strongest.status, + host: routedOwner, + ownedByHost: sessionOwner ? true : owner.ownedByHost, + status, selected: candidates.some((candidate) => candidate.selected), // A delayed status update in a cloud/SSH mirror must not make the task look // newly active or cause two simultaneously working keys to swap places. // Status and selection remain aggregated, but recency follows the backing // rollout owner whenever ownership is known. - activityAt: Math.max(...recencyCandidates.map((candidate) => candidate.activityAt ?? 0)), + activityAt: Math.max(sessionOwner?.session.activityAt ?? 0, ...recencyCandidates.map((candidate) => candidate.activityAt ?? 0)), observedAt: Math.max(...candidates.map((candidate) => candidate.observedAt)) }; } +function sessionOwnerIndex(inputs: HostSnapshot[]): Map { + const owners = new Map(); + for (const input of inputs) { + for (const session of input.snapshot.hostSessions ?? []) { + const identity = threadIdentity(session.threadId); + const prior = owners.get(identity); + if (!prior || session.activityAt > prior.session.activityAt) owners.set(identity, { input, session }); + } + } + return owners; +} + function compareActivity(left: RoutedAgentSlot, right: RoutedAgentSlot): number { if (left.selected !== right.selected) return left.selected ? -1 : 1; const status = hostStatusPriority(right.status) - hostStatusPriority(left.status); @@ -154,9 +269,24 @@ function compareActivity(left: RoutedAgentSlot, right: RoutedAgentSlot): number return (right.activityAt ?? 0) - (left.activityAt ?? 0) || left.sourceSlot - right.sourceSlot; } +function comparePriority(left: RoutedAgentSlot, right: RoutedAgentSlot): number { + return priorityModeStatus(right.status) - priorityModeStatus(left.status) || + Number(right.selected) - Number(left.selected) || + (right.activityAt ?? 0) - (left.activityAt ?? 0) || + left.sourceSlot - right.sourceSlot; +} + +function priorityModeStatus(status: string): number { + if (["approval", "awaiting-approval", "awaiting-response"].includes(status)) return 4; + if (["unread", "error", "complete", "completed", "done"].includes(status)) return 3; + if (["working", "thinking"].includes(status)) return 2; + if (status === "idle") return 1; + return 0; +} + function hostStatusPriority(status: string): number { if (["working", "thinking", "approval", "awaiting-approval", "awaiting-response"].includes(status)) return 3; - if (["unread", "error"].includes(status)) return 2; + if (["unread", "error", "complete", "completed", "done"].includes(status)) return 2; if (status === "idle") return 1; return 0; } @@ -169,5 +299,9 @@ function mirrorStatusPriority(status: string): number { return 0; } function isThreadKey(value: unknown): value is string { - return typeof value === "string" && /^(?:[a-z][a-z0-9_-]{0,15}:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); + return typeof value === "string" && /^(?:[a-z][a-z0-9_-]{0,31}:){0,3}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +function threadIdentity(value: string): string { + return value.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)?.[0]?.toLowerCase() ?? value; } diff --git a/src/session-ownership.ts b/src/session-ownership.ts index f8e0c9b..0865e91 100644 --- a/src/session-ownership.ts +++ b/src/session-ownership.ts @@ -1,13 +1,15 @@ -import { readdir } from "node:fs/promises"; +import { open, readdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { MicroSnapshot } from "./types.js"; +import type { HostSessionPresence, MicroSnapshot } from "./types.js"; const SESSION_FILENAME = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const THREAD_KEY = /(?:^|:)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; export class CodexSessionOwnershipIndex { private sessionIds = new Set(); + private recentSessions: HostSessionPresence[] = []; + private openedAt = new Map(); private refreshedAt = 0; private refreshInFlight?: Promise; @@ -20,6 +22,12 @@ export class CodexSessionOwnershipIndex { await this.refreshIfNeeded(now); return { ...snapshot, + hostSessions: this.recentSessions.map((session) => ({ + ...session, + status: session.status === "complete" && (this.openedAt.get(session.threadId) ?? 0) >= session.activityAt + ? "idle" + : session.status + })), slots: snapshot.slots.map((slot) => { const sessionId = sessionIdFromThreadKey(slot.threadKey); return { ...slot, ownedByHost: sessionId != null && this.sessionIds.has(sessionId) }; @@ -27,6 +35,11 @@ export class CodexSessionOwnershipIndex { }; } + markOpened(threadKey: string, now = Date.now()): void { + const sessionId = sessionIdFromThreadKey(threadKey); + if (sessionId) this.openedAt.set(sessionId, now); + } + private async refreshIfNeeded(now: number): Promise { if (now - this.refreshedAt < this.refreshIntervalMs) return; if (this.refreshInFlight) return this.refreshInFlight; @@ -38,13 +51,20 @@ export class CodexSessionOwnershipIndex { private async refresh(now: number): Promise { const next = new Set(); + const sessionFiles: Array<{ threadId: string; path: string }> = []; + const files: Array<{ threadId: string; path: string; activityAt: number }> = []; for (const root of this.roots) { try { const entries = await readdir(root, { recursive: true, withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; const sessionId = sessionIdFromRolloutFilename(entry.name); - if (sessionId) next.add(sessionId); + if (!sessionId) continue; + next.add(sessionId); + const parentPath = (entry as typeof entry & { parentPath?: string; path?: string }).parentPath + ?? (entry as typeof entry & { path?: string }).path; + if (!parentPath) continue; + sessionFiles.push({ threadId: sessionId, path: join(parentPath, entry.name) }); } } catch (error) { @@ -52,7 +72,30 @@ export class CodexSessionOwnershipIndex { if (code !== "ENOENT") throw error; } } + for (let index = 0; index < sessionFiles.length; index += 32) { + const batch = sessionFiles.slice(index, index + 32); + const resolved = await Promise.all(batch.map(async ({ threadId, path }) => { + try { + const info = await stat(path); + return { threadId, path, activityAt: info.mtimeMs }; + } catch { return null; } + })); + files.push(...resolved.filter((value): value is NonNullable => value != null)); + } this.sessionIds = next; + const uniqueRecent = new Map(); + for (const file of files.sort((left, right) => right.activityAt - left.activityAt)) { + if (!uniqueRecent.has(file.threadId)) uniqueRecent.set(file.threadId, file); + } + const recent = [...uniqueRecent.values()].slice(0, 128); + this.recentSessions = await Promise.all(recent.map(async ({ threadId, path, activityAt }) => ({ + threadId, + activityAt, + status: now - activityAt <= 15 * 60_000 ? await readRecentSessionStatus(path) : "idle" + }))); + for (const [threadId, openedAt] of this.openedAt) { + if (now - openedAt > 86_400_000) this.openedAt.delete(threadId); + } this.refreshedAt = now; } } @@ -69,3 +112,29 @@ function defaultSessionRoots(): string[] { const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex"); return [join(codexHome, "sessions"), join(codexHome, "archived_sessions")]; } + +async function readRecentSessionStatus(path: string): Promise { + try { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const length = Math.min(info.size, 512 * 1024); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, Math.max(0, info.size - length)); + const tail = buffer.toString("utf8"); + const completedAt = tail.lastIndexOf('"type":"task_complete"'); + const activeAt = Math.max( + tail.lastIndexOf('"type":"agent_reasoning"'), + tail.lastIndexOf('"type":"function_call"'), + tail.lastIndexOf('"type":"turn_context"') + ); + if (activeAt > completedAt) return "working"; + if (completedAt >= 0) return "complete"; + return "idle"; + } finally { + await handle.close(); + } + } catch { + return "idle"; + } +} diff --git a/src/status.ts b/src/status.ts index abf951a..91ec604 100644 --- a/src/status.ts +++ b/src/status.ts @@ -3,8 +3,14 @@ import type { AgentVisualStatus } from "./types.js"; export function visualStatusFromMicro(status: string): AgentVisualStatus { switch (status) { case "off": return "empty"; - case "working": return "thinking"; - case "unread": return "complete"; + case "working": + case "thinking": + return "thinking"; + case "unread": + case "complete": + case "completed": + case "done": + return "complete"; case "approval": case "awaiting-approval": case "awaiting-response": diff --git a/src/types.ts b/src/types.ts index a80a74a..9590294 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,12 +22,20 @@ export type MicroLayout = { analogStick: Record; }; +export type HostSessionPresence = { + threadId: string; + activityAt: number; + status: "idle" | "working" | "complete"; +}; + export type MicroSnapshot = { slots: MicroAgentSlot[]; layout: MicroLayout; agentSource: "pinned" | "recent" | "priority" | "custom"; lightingAutoOff: string; theme: ThemeMode; + /** Recent local rollout identities used to disambiguate cross-host mirrors. */ + hostSessions?: HostSessionPresence[]; }; export type CodexHost = { diff --git a/static/manifest.json b/static/manifest.json index b82eec3..0a1fe13 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json", "Name": "Codex Deck", - "Version": "0.6.0.0", + "Version": "0.6.1.0", "Author": "Dazer", "Description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay.", "UUID": "com.simeo.codex-deck", diff --git a/test/launcher.test.ts b/test/launcher.test.ts index 7717d32..a7481de 100644 --- a/test/launcher.test.ts +++ b/test/launcher.test.ts @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { buildRuntimeOverrideExpression, buildRuntimeVerificationExpression } from "../launcher/runtime-override.js"; +import { buildRuntimeOverrideExpression, buildRuntimeVerificationExpression, selectRuntimeTarget } from "../launcher/runtime-override.js"; const execFileAsync = promisify(execFile); @@ -19,6 +19,16 @@ test("launcher rejects an unsafe feature-gate expression", () => { assert.throws(() => buildRuntimeOverrideExpression("1);alert(1)//"), /digits only/); }); +test("runtime override targets the main renderer instead of macOS avatar surfaces", () => { + const target = selectRuntimeTarget([ + { type: "page", url: "app://-/index.html?initialRoute=%2Favatar-overlay", webSocketDebuggerUrl: "ws://route" }, + { type: "page", url: "app://-/avatar-overlay-composition-surface.html?surfaceId=mascot-badge", webSocketDebuggerUrl: "ws://mascot" }, + { type: "page", url: "app://-/index.html", webSocketDebuggerUrl: "ws://main" } + ]); + + assert.equal(target?.webSocketDebuggerUrl, "ws://main"); +}); + test("startup monitoring survives Codex updates without duplicate watchers", async () => { const [watcher, launcher, build] = await Promise.all([ readFile(new URL("../launcher/Watch-CodexDeck.ps1", import.meta.url), "utf8"), diff --git a/test/macos-launcher.test.ts b/test/macos-launcher.test.ts index 82e1a21..7cfcfea 100644 --- a/test/macos-launcher.test.ts +++ b/test/macos-launcher.test.ts @@ -43,6 +43,7 @@ test("LaunchAgent uses a dynamic Node resolver instead of pinning an NVM version assert.match(launcher, /Node\.js 20 or newer/); assert.match(plist, /\/bin\/zsh<\/string>/); assert.match(plist, /watcher-launch\.sh/); + assert.match(plist, /watcher\.stderr\.log/); assert.doesNotMatch(plist, /\.nvm\/versions\/node\/v\d/); }); diff --git a/test/macos-watcher.test.ts b/test/macos-watcher.test.ts index fc76825..ee7bdec 100644 --- a/test/macos-watcher.test.ts +++ b/test/macos-watcher.test.ts @@ -20,36 +20,37 @@ test("initial existing normal session remains untouched", () => { test("same process generation is never recovery-restarted repeatedly", () => { let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { - now: 0, generation: "A", bridgeHealthy: false + now: 0, generation: "A", bridgeHealthy: true }); - result = evaluateWatcherPolicy(result.state, { now: 6_000, generation: "B", bridgeHealthy: false }); + result = evaluateWatcherPolicy(result.state, { now: 30_000, generation: "B", bridgeHealthy: false }); + result = evaluateWatcherPolicy(result.state, { now: 40_000, generation: "B", bridgeHealthy: false }); assert.equal(result.action.type, "restart-for-recovery"); - result = evaluateWatcherPolicy(result.state, { now: 37_000, generation: "B", bridgeHealthy: false }); - assert.deepEqual(result.action, { type: "wait", reason: "recovery-already-attempted-for-generation" }); + result = evaluateWatcherPolicy(result.state, { now: 71_000, generation: "B", bridgeHealthy: false }); + assert.deepEqual(result.action, { type: "wait", reason: "automatic-recovery-circuit-open" }); }); test("rapid main-process replacement is detected without observing a stopped poll", () => { let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { - now: 0, generation: "A", bridgeHealthy: false + now: 0, generation: "A", bridgeHealthy: true }); - result = evaluateWatcherPolicy(result.state, { now: 6_000, generation: "B", bridgeHealthy: false }); + result = evaluateWatcherPolicy(result.state, { now: 30_000, generation: "B", bridgeHealthy: false }); + assert.deepEqual(result.action, { type: "wait", reason: "confirm-stable-unbridged-generation" }); + result = evaluateWatcherPolicy(result.state, { now: 40_000, generation: "B", bridgeHealthy: false }); assert.deepEqual(result.action, { type: "restart-for-recovery", generation: "B", - reason: "main-process-generation-changed" + reason: "previous-healthy-bridge-missing" }); }); -test("observed stopped interval triggers one bridge launch and waits for startup", () => { +test("an observed stopped interval never auto-launches Codex", () => { let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { now: 0, generation: "A", bridgeHealthy: true }); result = evaluateWatcherPolicy(result.state, { now: 6_000, generation: null, bridgeHealthy: false }); - assert.deepEqual(result.action, { type: "wait", reason: "confirm-stopped-interval" }); + assert.deepEqual(result.action, { type: "wait", reason: "codex-not-running" }); result = evaluateWatcherPolicy(result.state, { now: 8_001, generation: null, bridgeHealthy: false }); - assert.equal(result.action.type, "launch-bridge"); - result = evaluateWatcherPolicy(result.state, { now: 9_000, generation: null, bridgeHealthy: false }); - assert.deepEqual(result.action, { type: "wait", reason: "bridge-startup-pending" }); + assert.deepEqual(result.action, { type: "wait", reason: "codex-not-running" }); }); test("previous healthy bridge triggers recovery after app update replacement", () => { @@ -57,9 +58,24 @@ test("previous healthy bridge triggers recovery after app update replacement", ( now: 0, generation: "A:/Applications/Old.app", bridgeHealthy: true }); result = evaluateWatcherPolicy(result.state, { - now: 6_000, generation: "B:/Applications/New.app", bridgeHealthy: false + now: 30_000, generation: "B:/Applications/New.app", bridgeHealthy: false + }); + assert.equal(result.action.type, "wait"); + result = evaluateWatcherPolicy(result.state, { + now: 40_000, generation: "B:/Applications/New.app", bridgeHealthy: false + }); + assert.equal(result.action.type, "restart-for-recovery"); +}); + +test("a recovery attempt opens a global circuit across replacement generations", () => { + let result = evaluateWatcherPolicy(createWatcherPolicyState(0), { + now: 0, generation: "A", bridgeHealthy: true }); + result = evaluateWatcherPolicy(result.state, { now: 30_000, generation: "B", bridgeHealthy: false }); + result = evaluateWatcherPolicy(result.state, { now: 40_000, generation: "B", bridgeHealthy: false }); assert.equal(result.action.type, "restart-for-recovery"); + result = evaluateWatcherPolicy(result.state, { now: 71_000, generation: "C", bridgeHealthy: false }); + assert.deepEqual(result.action, { type: "wait", reason: "automatic-recovery-circuit-open" }); }); test("LaunchAgent startup race waits, preserves a fresh install, and recovers prior bridge state", () => { @@ -74,8 +90,8 @@ test("LaunchAgent startup race waits, preserves a fresh install, and recovers pr }).state; prior = resumeWatcherPolicyState(prior, 100_000); let resumed = evaluateWatcherPolicy(prior, { now: 101_000, generation: "LOGIN", bridgeHealthy: false }); - assert.deepEqual(resumed.action, { type: "wait", reason: "launch-agent-startup-grace" }); - resumed = evaluateWatcherPolicy(resumed.state, { now: 106_000, generation: "LOGIN", bridgeHealthy: false }); + assert.deepEqual(resumed.action, { type: "wait", reason: "bridge-startup-pending" }); + resumed = evaluateWatcherPolicy(resumed.state, { now: 130_000, generation: "LOGIN", bridgeHealthy: false }); assert.equal(resumed.action.type, "restart-for-recovery"); }); diff --git a/test/micro-bridge.test.ts b/test/micro-bridge.test.ts index 7e7eea2..09b870b 100644 --- a/test/micro-bridge.test.ts +++ b/test/micro-bridge.test.ts @@ -1,14 +1,16 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { REASONING_COMMANDS } from "../src/codex-micro-renderer-bridge.js"; +import { REASONING_COMMANDS, retainEvaluationPromise, selectCodexMainTarget } from "../src/codex-micro-renderer-bridge.js"; import { ADDITIONAL_KEYCAPS, OFFICIAL_KEYCAP_IDS } from "../src/keycaps.js"; import { visualStatusFromMicro } from "../src/status.js"; test("official Micro statuses map to the Stream Deck color states", () => { assert.equal(visualStatusFromMicro("off"), "empty"); assert.equal(visualStatusFromMicro("working"), "thinking"); + assert.equal(visualStatusFromMicro("thinking"), "thinking"); assert.equal(visualStatusFromMicro("unread"), "complete"); + assert.equal(visualStatusFromMicro("done"), "complete"); assert.equal(visualStatusFromMicro("approval"), "input"); assert.equal(visualStatusFromMicro("awaiting-approval"), "input"); assert.equal(visualStatusFromMicro("awaiting-response"), "input"); @@ -32,10 +34,42 @@ test("renderer bridge uses native Micro events and discovers hashed modules at r assert.match(source, /createSubscriberAtom/); assert.match(source, /slots\.length === 6/); assert.match(source, /codex-micro-agent-source/); + assert.match(source, /directSettingReader/); + assert.match(source, /get-setting/); + assert.match(source, /found\.node\.store\.get\.bind\(found\.node\.store\)/); assert.doesNotMatch(source, /candidate\?\.token === appScope/); assert.doesNotMatch(source, /D90_rd6W|SFcKxWqG|DJFcGyy5/); }); +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" }, + { type: "page", url: "app://-/avatar-overlay-composition-surface.html?surfaceId=mascot-badge", webSocketDebuggerUrl: "ws://mascot" }, + { type: "page", url: "app://-/avatar-overlay-composition-surface.html?surfaceId=activity-slot-0", webSocketDebuggerUrl: "ws://slot" }, + { type: "page", url: "app://-/index.html", webSocketDebuggerUrl: "ws://main" } + ]); + + assert.equal(target?.webSocketDebuggerUrl, "ws://main"); +}); + +test("renderer bridge rejects auxiliary-only renderer lists", () => { + const target = selectCodexMainTarget([ + { type: "page", url: "app://-/avatar-overlay-composition-surface.html?surfaceId=mascot-badge", webSocketDebuggerUrl: "ws://mascot" } + ]); + + assert.equal(target, undefined); +}); + +test("renderer evaluations retain their awaited promise until CDP has collected the result", () => { + const expression = retainEvaluationPromise("(async () => true)()", 17); + assert.match(expression, /__codexDeckPendingEvaluations/); + assert.match(expression, /codex-deck-17/); + assert.match(expression, /Promise\.resolve/); + assert.match(expression, /setTimeout\(\(\) => store\.delete/); + const namespaced = retainEvaluationPromise("Promise.resolve(true)", "bridge-a-1"); + assert.match(namespaced, /codex-deck-bridge-a-1/); +}); + test("reasoning controls use the official native composer commands", async () => { assert.deepEqual(REASONING_COMMANDS, { decrease: "composer.decreaseReasoningEffort", diff --git a/test/relay.test.ts b/test/relay.test.ts index f12a333..60b6c3b 100644 --- a/test/relay.test.ts +++ b/test/relay.test.ts @@ -43,9 +43,21 @@ test("relay command parser permits only the narrow native command surface", () = assert.equal(parseRelayCommand({ kind: "evaluate", expression: "process.exit()" }), null); assert.equal(parseRelayCommand({ kind: "keycap", keycapId: "NOT_REAL" }), null); assert.notEqual(parseRelayCommand({ kind: "agent", slot: 1, threadKey: "local:019f6de7-44c2-7fe2-9d17-9322c952e626", act: 1 }), null); + assert.notEqual(parseRelayCommand({ kind: "agent", slot: 0, threadKey: "client-new-thread:e3c18619-71ff-4a8d-8dd3-d475e9bcf162", act: 1 }), null); + assert.notEqual(parseRelayCommand({ kind: "agent", slot: 0, threadKey: "local:client-new-thread:e3c18619-71ff-4a8d-8dd3-d475e9bcf162", act: 1 }), null); assert.equal(parseRelayCommand({ kind: "agent", slot: 1, threadKey: "local:../../secret", act: 1 }), 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) }; + valid.snapshot.hostSessions = [{ threadId: "00000000-0000-4000-8000-000000000000", activityAt: 1, status: "working" }]; + assert.notEqual(parseRelayServerMessage(valid), null); + const invalid = structuredClone(valid) as typeof valid & { snapshot: { hostSessions: unknown[] } }; + invalid.snapshot.hostSessions = Array.from({ length: 129 }, () => valid.snapshot.hostSessions![0]!); + assert.equal(parseRelayServerMessage(invalid), null); +}); + test("host activity merge globally orders explicit Mac and Windows timestamps", () => { const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; const macSnapshot = structuredClone(snapshot); @@ -117,6 +129,39 @@ test("backing rollout ownership beats a mirrored remote-SSH recent entry", () => assert.equal(match?.selected, true, "selection is aggregated across both visible mirrors"); }); +test("host session catalogs route a mirror even when the owning host has no native slot for it", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const shared = "00000000-0000-4000-8000-000000000000"; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: "40000000-0000-4000-8000-000000000099", status: "idle" }; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: shared, title: "Mac-owned task", status: "idle", ownedByHost: false }; + macSnapshot.hostSessions = [{ threadId: shared, activityAt: 2_000, status: "working" }]; + const match = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 2_000 }, + { host, snapshot: macSnapshot, observedAt: 2_000 } + ], 2_000, windows.hostId).find((slot) => slot.threadKey === shared); + assert.equal(match?.host.platform, "darwin"); + assert.equal(match?.status, "working"); + assert.equal(match?.title, "Mac-owned task"); +}); + +test("host session catalogs return a Mac-only cloud mirror to its Windows owner", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const shared = "00000000-0000-4000-8000-000000000000"; + const macSnapshot = structuredClone(snapshot); + const windowsSnapshot = structuredClone(snapshot); + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: shared, status: "working", ownedByHost: false }; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: "40000000-0000-4000-8000-000000000099", status: "idle" }; + windowsSnapshot.hostSessions = [{ threadId: shared, activityAt: 2_000, status: "idle" }]; + const match = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 2_000 }, + { host, snapshot: macSnapshot, observedAt: 2_000 } + ], 2_000, windows.hostId).find((slot) => slot.threadKey === shared); + assert.equal(match?.host.platform, "win32"); + assert.equal(match?.status, "working"); +}); + test("delayed mirror status does not reorder an owned active task", () => { const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; const shared = "00000000-0000-4000-8000-000000000000"; @@ -177,6 +222,141 @@ test("the same cloud thread is shown once and owned by its live active host", () assert.equal(afterCompletion?.host.platform, "darwin", "the host that completed the task retains ownership"); }); +test("single-host agent modes preserve Codex's native six-slot order", () => { + const pinned = structuredClone(snapshot); + pinned.agentSource = "pinned"; + for (const slot of pinned.slots) { + slot.status = "idle"; + slot.selected = false; + slot.activityAt = slot.id; + } + const merged = new HostActivityIndex().merge([{ host, snapshot: pinned, observedAt: 1_000 }], 1_000, host.hostId); + assert.deepEqual(merged.map((slot) => slot.threadKey), pinned.slots.map((slot) => slot.threadKey)); + assert.deepEqual(merged.map((slot) => slot.id), [0, 1, 2, 3, 4, 5]); +}); + +test("combined pinned mode interleaves both hosts and routes mirrored tasks to the owner", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const shared = "20000000-0000-4000-8000-000000000000"; + const windowsSnapshot = structuredClone(snapshot); + const macSnapshot = structuredClone(snapshot); + windowsSnapshot.agentSource = "pinned"; + macSnapshot.agentSource = "pinned"; + for (const slot of windowsSnapshot.slots) slot.threadKey = `21000000-0000-4000-8000-00000000000${slot.id}`; + for (const slot of macSnapshot.slots) slot.threadKey = `22000000-0000-4000-8000-00000000000${slot.id}`; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: shared, ownedByHost: false }; + macSnapshot.slots[4] = { ...macSnapshot.slots[4]!, threadKey: shared, ownedByHost: true }; + const merged = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 }, + { host, snapshot: macSnapshot, observedAt: 1_000 } + ], 1_000, windows.hostId); + assert.equal(merged[0]!.threadKey, shared); + assert.equal(merged[0]!.host.platform, "darwin"); + assert.equal(merged[0]!.sourceSlot, 4); + assert.deepEqual(merged.slice(1).map((slot) => slot.threadKey), [ + macSnapshot.slots[0]!.threadKey, + windowsSnapshot.slots[1]!.threadKey, + macSnapshot.slots[1]!.threadKey, + windowsSnapshot.slots[2]!.threadKey, + macSnapshot.slots[2]!.threadKey + ]); +}); + +test("combined custom mode uses the remote assignment when the controller slot is empty", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const windowsSnapshot = structuredClone(snapshot); + const macSnapshot = structuredClone(snapshot); + windowsSnapshot.agentSource = "custom"; + macSnapshot.agentSource = "custom"; + windowsSnapshot.slots[0] = { id: 0, threadKey: null, title: null, status: "off", selected: false }; + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: "30000000-0000-4000-8000-000000000000", ownedByHost: true }; + const merged = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 }, + { host, snapshot: macSnapshot, observedAt: 1_000 } + ], 1_000, windows.hostId); + assert.equal(merged[0]!.threadKey, macSnapshot.slots[0]!.threadKey); + assert.equal(merged[0]!.host.platform, "darwin"); + assert.equal(merged[0]!.sourceSlot, 0); +}); + +test("combined custom mode keeps the controller assignment when both hosts configure one button", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const windowsSnapshot = structuredClone(snapshot); + const macSnapshot = structuredClone(snapshot); + windowsSnapshot.agentSource = "custom"; + macSnapshot.agentSource = "custom"; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: "31000000-0000-4000-8000-000000000000" }; + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: "32000000-0000-4000-8000-000000000000" }; + const merged = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 }, + { host, snapshot: macSnapshot, observedAt: 1_000 } + ], 1_000, windows.hostId); + assert.equal(merged[0]!.threadKey, windowsSnapshot.slots[0]!.threadKey); + assert.equal(merged[0]!.host.platform, "win32"); +}); + +test("combined custom mode de-duplicates prefixed mirrors and routes them to the rollout owner", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const windowsSnapshot = structuredClone(snapshot); + const macSnapshot = structuredClone(snapshot); + windowsSnapshot.agentSource = "custom"; + macSnapshot.agentSource = "custom"; + const id = "33000000-0000-4000-8000-000000000000"; + windowsSnapshot.slots[0] = { ...windowsSnapshot.slots[0]!, threadKey: `local:${id}`, ownedByHost: false }; + macSnapshot.slots[1] = { ...macSnapshot.slots[1]!, threadKey: `local:client-new-thread:${id}`, ownedByHost: true }; + const merged = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 }, + { host, snapshot: macSnapshot, observedAt: 1_000 } + ], 1_000, windows.hostId); + assert.equal(merged.filter((slot) => slot.threadKey?.endsWith(id)).length, 1); + assert.equal(merged[0]!.host.platform, "darwin"); + assert.equal(merged[0]!.sourceSlot, 1); + assert.equal(merged[1]!.threadKey, windowsSnapshot.slots[1]!.threadKey); +}); + +test("combined priority mode ranks waiting, unread, active, then idle", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const windowsSnapshot = structuredClone(snapshot); + const macSnapshot = structuredClone(snapshot); + windowsSnapshot.agentSource = "priority"; + for (const slot of [...windowsSnapshot.slots, ...macSnapshot.slots]) { + slot.status = "idle"; + slot.selected = false; + slot.activityAt = 1; + } + macSnapshot.slots[0] = { ...macSnapshot.slots[0]!, threadKey: "40000000-0000-4000-8000-000000000000", status: "working" }; + macSnapshot.slots[1] = { ...macSnapshot.slots[1]!, threadKey: "40000000-0000-4000-8000-000000000001", status: "unread" }; + macSnapshot.slots[2] = { ...macSnapshot.slots[2]!, threadKey: "40000000-0000-4000-8000-000000000002", status: "awaiting-approval" }; + const merged = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 1_000 }, + { host, snapshot: macSnapshot, observedAt: 1_000 } + ], 1_000, windows.hostId); + assert.deepEqual(merged.slice(0, 3).map((slot) => slot.status), ["awaiting-approval", "unread", "working"]); +}); + +test("combined priority mode keeps freshly completed owner sessions ahead of idle tasks", () => { + const windows: CodexHost = { hostId: "11111111-1111-4111-8111-111111111111", hostName: "Windows", platform: "win32" }; + const windowsSnapshot = structuredClone(snapshot); + const macSnapshot = structuredClone(snapshot); + const completed = "50000000-0000-4000-8000-000000000000"; + windowsSnapshot.agentSource = "priority"; + macSnapshot.agentSource = "priority"; + for (const slot of [...windowsSnapshot.slots, ...macSnapshot.slots]) { + slot.status = "idle"; + slot.selected = false; + slot.activityAt = 1; + } + windowsSnapshot.slots[4] = { ...windowsSnapshot.slots[4]!, threadKey: completed, status: "idle" }; + macSnapshot.hostSessions = [{ threadId: completed, activityAt: 2_000, status: "complete" }]; + const merged = new HostActivityIndex().merge([ + { host: windows, snapshot: windowsSnapshot, observedAt: 2_000 }, + { host, snapshot: macSnapshot, observedAt: 2_000 } + ], 2_000, windows.hostId); + assert.equal(merged[0]!.threadKey, completed); + assert.equal(merged[0]!.host.platform, "darwin"); + assert.equal(merged[0]!.status, "complete"); +}); + test("authenticated relay publishes snapshots and dispatches typed commands", async () => { const port = await freePort(); const calls: unknown[] = []; @@ -231,6 +411,31 @@ test("relay rejects a client with the wrong token before publishing state", asyn await server.close(); }); +test("authenticated relay survives an unavailable Codex snapshot", async () => { + const port = await freePort(); + const logs: string[] = []; + const control = { + refresh: async (): Promise => { throw new Error("bridge offline"); }, + sendAgent: async () => {}, sendAction: async () => {}, sendJoystick: async () => {}, + sendEncoder: async () => {}, adjustReasoning: async () => {}, runKeycap: async () => {} + }; + const server = new CodexRelayServer( + { enabled: true, listenHost: "127.0.0.1", port, token: "t".repeat(32) }, host, control, + (message) => logs.push(message) + ); + await server.start(); + const socket = new WebSocket(`ws://127.0.0.1:${port}`); + const messages = messageQueue(socket); + await onceOpen(socket); + socket.send(JSON.stringify({ type: "auth", protocol: RELAY_PROTOCOL_VERSION, token: "t".repeat(32) })); + assert.equal((await messages.next()).type, "ready"); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(socket.readyState, WebSocket.OPEN); + assert.equal(logs.filter((message) => message.includes("bridge offline")).length, 1); + socket.close(); + await server.close(); +}); + async function freePort(): Promise { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); diff --git a/test/release-audit.test.ts b/test/release-audit.test.ts new file mode 100644 index 0000000..899ce36 --- /dev/null +++ b/test/release-audit.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const auditScript = fileURLToPath(new URL("../scripts/audit-release.mjs", import.meta.url)); + +test("release audit accepts explicit clean roots and rejects private state", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-deck-audit-")); + try { + const clean = join(root, "clean"); + await mkdir(clean); + await writeFile(join(clean, "README.txt"), "public release fixture\n", "utf8"); + const cleanResult = spawnSync(process.execPath, [auditScript, clean], { encoding: "utf8" }); + assert.equal(cleanResult.status, 0, cleanResult.stderr); + assert.match(cleanResult.stdout, /passed for 1 artifact roots/); + + await writeFile(join(clean, "relay-client.json"), "{}\n", "utf8"); + const privateResult = spawnSync(process.execPath, [auditScript, clean], { encoding: "utf8" }); + assert.equal(privateResult.status, 1); + assert.match(privateResult.stderr, /private runtime state must not be packaged/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/session-ownership.test.ts b/test/session-ownership.test.ts index 99d5b62..deddb82 100644 --- a/test/session-ownership.test.ts +++ b/test/session-ownership.test.ts @@ -30,6 +30,28 @@ test("rollout and prefixed thread identities use the same UUID", () => { assert.equal(sessionIdFromThreadKey("local:../../secret"), null); }); +test("recent local rollout tails expose structural working and completion state without task contents", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-deck-presence-")); + try { + const activeId = "10000000-0000-4000-8000-000000000001"; + const completeId = "10000000-0000-4000-8000-000000000002"; + await writeFile(join(root, `rollout-now-${activeId}.jsonl`), '{"type":"event_msg","payload":{"type":"agent_reasoning"}}\n'); + await writeFile(join(root, `rollout-now-${completeId}.jsonl`), '{"type":"event_msg","payload":{"type":"agent_reasoning"}}\n{"type":"event_msg","payload":{"type":"task_complete"}}\n'); + const annotated = await new CodexSessionOwnershipIndex([root], 60_000).annotate(snapshot()); + const states = new Map(annotated.hostSessions?.map((session) => [session.threadId, session.status])); + assert.equal(states.get(activeId), "working"); + assert.equal(states.get(completeId), "complete"); + const index = new CodexSessionOwnershipIndex([root], 60_000); + const beforeOpen = await index.annotate(snapshot()); + assert.equal(beforeOpen.hostSessions?.find((session) => session.threadId === completeId)?.status, "complete"); + index.markOpened(`local:${completeId}`); + const afterOpen = await index.annotate(snapshot()); + assert.equal(afterOpen.hostSessions?.find((session) => session.threadId === completeId)?.status, "idle"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function snapshot(): MicroSnapshot { return { slots: Array.from({ length: 6 }, (_, id) => ({ diff --git a/test/status.test.ts b/test/status.test.ts index 511713c..3db8ebf 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -5,7 +5,10 @@ import { visualStatusFromMicro } from "../src/status.js"; test("native Micro states map to the Stream Deck status palette", () => { assert.equal(visualStatusFromMicro("off"), "empty"); assert.equal(visualStatusFromMicro("working"), "thinking"); + assert.equal(visualStatusFromMicro("thinking"), "thinking"); assert.equal(visualStatusFromMicro("unread"), "complete"); + assert.equal(visualStatusFromMicro("done"), "complete"); + assert.equal(visualStatusFromMicro("completed"), "complete"); assert.equal(visualStatusFromMicro("approval"), "input"); assert.equal(visualStatusFromMicro("awaiting-approval"), "input"); assert.equal(visualStatusFromMicro("awaiting-response"), "input");