Open-source replica of Work Louder's Codex Micro using a consumer gamepad (Sony DualSense first; DS4/Xbox/generic input-only) as the physical AI-agent controller. Harness-agnostic core targeting Claude Code and Codex CLI from day one; any other agent CLI can be added by implementing one interface.
Foundation: vendored modules from /Users/stephenleo/Developer/vibesense (same author). TypeScript, Node >= 22, ESM. Deps: dualsense-ts, node-hid, node-pty, zod only. Tests: vitest. macOS-first.
| Codex Micro | openmicro |
|---|---|
| 6 RGB agent keys (live thread status) | Lightbar = focused session's state color; 5 player LEDs = occupied session slots (cap 5, documented gap vs 6). No rumble — Codex Micro has no haptics, strict parity |
| Command keys: accept / reject / push-to-talk / new chat | Face buttons: south=accept, east=reject, north=push-to-talk, west=new chat |
| Joystick → 4 preset workflows | Left-stick flick (up/down/left/right) → prompt templates (review PR / debug / refactor / write tests), remappable |
| Rotary dial → thinking depth | Right-stick rotation gesture (a real dial replica): accumulate angular sweep while deflected past deadzone, one depth step per 90° — CW = up, CCW = down. Remappable (e.g. onto the left stick) via config; d-pad stays arrow-keys for TUI menus, L2/R2 unbound by default |
| Touch sensor | DualSense touchpad click = cycle focused session |
| 6 programmable layers | Hold L1 + face/dpad-up/dpad-down = layer 0–5; lightbar flashes layer tint on switch |
| Remap in ChatGPT app | ~/.openmicro/config.json, zod-validated |
| Bluetooth / USB-C | DualSense USB + Bluetooth via dualsense-ts |
openmicro [claude|codex] [...args] wraps the agent CLI in a pty (vibesense pattern). First instance = host (binds port 48762, owns controller + session aggregation); later instances = clients (register, receive forwarded keystrokes over SSE). Agent lifecycle hooks POST to http://127.0.0.1:48762/om-hook/<event>.
IMPORTANT — coexistence with vibesense (verified in Phase 2): vibesense's installer purges any hook command containing the bare substring /hook/, so openmicro uses path /om-hook/ (which does not contain /hook/) on port 48762, and identifies its own entries by the full marker 127.0.0.1:48762/om-hook/. Phase 4's server must route /om-hook/<event>. Constants live once in src/ports.ts.
export type AgentKind = 'claude' | 'codex' | (string & {}) // widened so third-party kinds satisfy Harness without a cast
export type AgentState = 'executing' | 'waiting' | 'idle' | 'complete' | 'error'
export type Action =
| { type: 'accept' }
| { type: 'reject' }
| { type: 'push_to_talk' }
| { type: 'new_chat' }
| { type: 'thinking_depth'; delta: 1 | -1 }
| { type: 'workflow'; presetId: string } // core resolves presetId → text via config, then calls resolveAction({type:'prompt', text})
| { type: 'prompt'; text: string }
| { type: 'focus_session'; index: number } // handled by core, never reaches a Harness
| { type: 'layer'; index: number } // handled by core, never reaches a Harness
| { type: 'keys'; bytes: string } // raw pty passthrough (e.g. dpad arrows), user-remappable
export interface InstallResult {
changed: boolean
trustNotice: string | null
}
export interface Harness {
readonly kind: AgentKind
readonly command: string
buildArgs(userArgs: string[]): string[]
installHooks(): InstallResult
/** Hook event name + raw payload → state, null if not state-relevant. Error/complete are best-effort heuristics per harness. */
stateForHookEvent(event: string, payload: unknown): AgentState | null
/** Action → pty bytes (+ new thinking level when applicable). null = harness has no equivalent (documented gap, never faked). */
resolveAction(
action: Action,
ctx: { thinkingLevel: number },
): { bytes: string; thinkingLevel?: number } | null
}
export function harnessFor(kind: string): HarnessPublic extension API: package.json gets an exports map exposing openmicro/harness (the Harness/Action/AgentState types + registerHarness(harness: Harness) and the built-in registry). AgentKind widens to string at the registry boundary so third-party harnesses (Gemini CLI, opencode, …) register without forking. README gets an "Adding a harness" section with a minimal example. Scope discipline for the initial commit: Codex Micro's current functionality, 100%, nothing else.
Core modules (controller, state, router, layers, feedback, server, dispatch, cli) import only these types — never 'claude'/'codex' literals outside src/harness/. Adding Gemini CLI later = one new file + one registry entry.
| openmicro file | Origin | Changes |
|---|---|---|
src/types.ts |
vendor | add 'touchpad' to ButtonId |
src/controller/hal.ts |
vendor verbatim | — |
src/controller/hid-manager.ts |
vendor verbatim | — |
src/controller/{ds4,xbox,generic,raw-hid}-driver.ts |
vendor verbatim | input-only, no output support |
src/controller/dualsense-driver.ts |
vendor + extend | add output: ControllerOutput (lightbar/playerLeds/rumble via dualsense-ts) + touchpad click events |
src/controller/output.ts |
new | interface ControllerOutput { setLightbar(rgb): void; setPlayerLeds(bitmask): void }; drivers expose output? — optional-chained no-op on non-DualSense. No rumble (strict Codex Micro parity) |
src/pty.ts |
vendor verbatim | AgentPty + spawn-helper permission fix |
src/logger.ts |
vendor | log path ~/.openmicro/openmicro.log |
src/hooks-install.ts |
vendor | port 48762, base-URL marker (see coexistence note) |
src/state.ts |
vendor + extend | keep SessionTracker; drop PauseGate; add complete (transient: green decay N seconds after Stop) and error states; stateForHookEvent moves behind Harness |
src/server.ts |
vendor + trim | keep singleton bind, /om-hook/<event>, /register, /instance/<id> SSE, sendKeysToInstance; delete GameProvider/sidebar//events/static serving |
src/client.ts |
vendor | rename vibesense strings |
src/keymap.ts |
vendor KeyRepeater only |
TERMINAL_KEYS replaced by layer config |
src/router.ts |
new (pattern reused) | LayerRouter: ControllerEvent + current layer → Action | null; keep vibesense's 750 ms guard-window-kills-held-buttons on every layer flip; stick gesture detectors live here: flick (threshold cross + return-to-center) and rotation (angle accumulation while past deadzone, emit one event per 90° sweep, direction-signed) |
src/layers.ts |
new | zod schema: 6 layers, each { name, color, bindings: Partial<Record<ControlId, Action>> } + workflows: Record<string, string>; ControlId = ButtonId | 'lstick_up' | 'lstick_down' | 'lstick_left' | 'lstick_right' | 'lstick_cw' | 'lstick_ccw' | 'rstick_up' | … | 'rstick_cw' | 'rstick_ccw'; load/save ~/.openmicro/config.json (atomic tmp+rename); DEFAULT_CONFIG = parity-map bindings above (lstick flicks = workflows, rstick_cw/ccw = thinking depth ±1) |
src/feedback.ts |
new | pure feedbackFor(snapshot, layer) → { lightbar, playerLeds }; state colors: executing=blue, waiting=amber, idle=dim white, complete=green (decay), error=red; debounced apply loop in cli |
src/harness/{types,claude,codex,index}.ts |
new | contract above; claude/codex resolveAction keybindings empirically verified against the real CLIs, never guessed; thinking depth: Claude = effort/model cycling, Codex = /model reasoning-effort — verify, and return null where unsupported |
src/invocation.ts |
vendor + trim | `openmicro [claude |
src/dispatch.ts |
new | dispatchAction(action, deps): Action → effect, split out of cli.ts so it's unit-testable without HID/pty/timer wiring; focus_session/layer are core-only, workflow resolves its preset text through config then hands the harness a plain prompt, everything else goes straight to harness.resolveAction (null = documented gap, silently skipped) |
src/cli.ts |
new (skeleton borrowed) | install hooks → bind singleton port (host/client branch) → spawn AgentPty; client: register + stream forwarded keystrokes; host: HID start → route → dispatch (harness actions → pty write; layer/focus → core state; snapshot changes → feedback) |
test/* |
vendor tests for vendored modules; new tests for router/layers/feedback/harness/dispatch | keep vibesense's proven edge cases (guard window, hook-merge idempotency) |
| toolchain configs | vendor verbatim from vibesense | package name openmicro, bin openmicro |
- Scaffold + vendor verbatim modules;
npm install, typecheck green. ✅ when done - Controller output + touchpad (dualsense-driver ext, output.ts, feedback.ts) + hardware spike script
scripts/spike-output.ts(lightbar/LED/rumble on a real pad) - Harness layer (types/claude/codex/index, hooks-install adaptation, state.ts extension) — includes empirical keybinding verification
- Layers + router + gestures (layers.ts, router.ts, KeyRepeater wiring)
- Server/client trim + invocation + cli.ts integration
- Tests + README; full verification gate:
npm test && npm run lint && npm run typecheck && npm run format:check(match vibesense script names)
- 5 LED slots vs Codex Micro's 6 agent keys (DualSense has 5 player LEDs).
error/completeare heuristics — hooks provide no ground-truth error signal.- Xbox/DS4/generic: input only, no RGB/rumble feedback path.
- Xbox driver is wired-USB report layout only (inherited from vibesense).