# OBS Pill Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** An island pill that exists while OBS runs and controls recording, pause, scene and webcam visibility, with a pulsing sliver while recording. **Architecture:** `Obs.qml` singleton speaks obs-websocket v5 over QML `WebSocket` (QtWebSockets), reading the password from OBS's own config; the SHA256 auth string comes from `obs-auth.sh` (openssl). `ObsPill.qml` renders state and calls the singleton's actions. `Island.qml` places the pill right of the status pill and draws the pulse over the sliver. **Tech Stack:** Quickshell 0.3.1 QML, QtWebSockets, openssl, node (for `test_logic.js`). Spec: `docs/superpowers/specs/2026-10-17-obs-pill-design.md`. **Spec deviations, found while planning:** - QML `WebSocket` exposes no close code, so an auth failure (4009) cannot be told apart from OBS closing. The singleton logs "closed before Identified" once per run of failures instead. - Responses route on `requestType`, which obs-websocket echoes in every response; no prefixed `requestId` is needed. ## Files - Create `desktop/island/obs-auth.sh`: stdin `password+salt` line, `challenge` line; stdout the auth string. - Create `desktop/island/Obs.qml`: singleton, protocol and state. - Create `desktop/island/ObsPill.qml`: the pill. - Modify `desktop/island/Logic.js`, `desktop/island/test_logic.js`: `obsElapsed`, `obsStep`. - Modify `desktop/island/Island.qml`: hover, mask, placement, sliver pulse. - Modify `desktop/island/README.md`, `AGENTS.md`: docs. ## Constraints every task must respect - GPLv2 header on every new file (copy from `privacy.sh` / `Privacy.qml`). - Glyphs render with `Theme.iconFamily`. Checked present in Inconsolata Nerd Font: `\uf111` fa-circle, `\uf04c` fa-pause, `\uf04b` fa-play, `\u{F0567}` md-video, `\u{F0568}` md-video_off. - Never run `qs` detached or `reload.sh` from a tool call (AGENTS.md). New files in `island/` need `./reload.sh desktop` from the user's terminal to be indexed. - Process name is `qs`; never `pkill -f`. --- ### Task 1: Logic helpers **Files:** Modify `desktop/island/Logic.js`, `desktop/island/test_logic.js` - [ ] **Step 1: Failing tests.** In `test_logic.js`, extend the return list to `{ volumeIcon, vmRole, flagFor, privacyRoles, parseOpenWindow, obsElapsed, obsStep }` and add before `console.log("ok")`: ```js // OBS elapsed: mm:ss under an hour, h:mm:ss past it. assert.strictEqual(L.obsElapsed(0), "00:00"); assert.strictEqual(L.obsElapsed(61000), "01:01"); assert.strictEqual(L.obsElapsed(3599999), "59:59"); assert.strictEqual(L.obsElapsed(3661000), "1:01:01"); assert.strictEqual(L.obsElapsed(undefined), "00:00"); // OBS scene step: wraps both ways; an unknown current scene starts at the first. assert.strictEqual(L.obsStep(["a", "b", "c"], "a", 1), "b"); assert.strictEqual(L.obsStep(["a", "b", "c"], "c", 1), "a"); assert.strictEqual(L.obsStep(["a", "b", "c"], "a", -1), "c"); assert.strictEqual(L.obsStep(["a", "b", "c"], "x", 1), "a"); assert.strictEqual(L.obsStep([], "x", 1), ""); ``` - [ ] **Step 2:** `node desktop/island/test_logic.js` → FAIL, `obsElapsed is not defined`. - [ ] **Step 3: Implement** at the end of `Logic.js`: ```js // OBS's outputDuration (ms) as mm:ss, or h:mm:ss past an hour. function obsElapsed(ms) { const s = Math.floor((ms > 0 ? ms : 0) / 1000); const two = n => String(n).padStart(2, "0"); const h = Math.floor(s / 3600); const mmss = `${two(Math.floor(s / 60) % 60)}:${two(s % 60)}`; return h ? `${h}:${mmss}` : mmss; } // The scene d steps (+1 next, -1 previous) from cur, wrapping. A current // scene missing from the list starts at the first. function obsStep(list, cur, d) { if (!list.length) return ""; const i = list.indexOf(cur); if (i < 0) return list[0]; return list[(i + d + list.length) % list.length]; } ``` - [ ] **Step 4:** `node desktop/island/test_logic.js` → `ok`. - [ ] **Step 5: Commit** `island: elapsed and scene-step helpers for the OBS pill`. ### Task 2: Auth helper **Files:** Create `desktop/island/obs-auth.sh` (mode 755) - [ ] **Step 1: Write it** (GPL header as in `privacy.sh`, then): ```sh # obs-websocket v5 authentication, which QML cannot compute: it has no # SHA256. stdin is two lines, password+salt then challenge, so neither # reaches a command line. stdout is # base64(sha256(base64(sha256(password + salt)) + challenge)) read -r ps || exit 1 read -r ch || exit 1 secret=$(printf %s "$ps" | openssl dgst -sha256 -binary | base64) printf %s "$secret$ch" | openssl dgst -sha256 -binary | base64 ``` - [ ] **Step 2: Check against Python** (one-off, not committed): ```sh printf 'pwSALT\nCHAL\n' | sh desktop/island/obs-auth.sh /usr/bin/python3 -c "import hashlib,base64;s=base64.b64encode(hashlib.sha256(b'pwSALT').digest());print(base64.b64encode(hashlib.sha256(s+b'CHAL').digest()).decode())" ``` Expected: identical lines. - [ ] **Step 3: Commit** `island: obs-websocket auth helper`. ### Task 3: `Obs.qml` singleton **Files:** Create `desktop/island/Obs.qml` - [ ] **Step 1: Write it** (GPL header, then): ```qml pragma Singleton import Quickshell import Quickshell.Io import QtQuick import QtWebSockets // OBS Studio over obs-websocket v5. Connected means OBS is running with // its server enabled, so nothing checks for the process. State follows // OBS's events; actions only send requests and never set state // optimistically. Singleton { id: root property bool connected: false property bool recording: false property bool paused: false property real duration: 0 property var scenes: [] property string scene: "" // The webcam: the first v4l2_input item in the current scene, -1 for none. // ponytail: a webcam inside a group or nested scene is not found; walk // GetGroupSceneItemList if one ever is. property int camId: -1 property bool camOn: false function toggleRecord() { request("ToggleRecord"); } function togglePause() { request("ToggleRecordPause"); } function setScene(name) { if (name) request("SetCurrentProgramScene", { sceneName: name }); } function toggleCam() { if (camId >= 0) request("SetSceneItemEnabled", { sceneName: scene, sceneItemId: camId, sceneItemEnabled: !camOn }); } // --- config ----------------------------------------------------------- // OBS's own file, so no copy of the password exists anywhere else. FileView { id: cfg path: `${Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config"}/obs-studio/plugin_config/obs-websocket/config.json` watchChanges: true onFileChanged: reload() } function config() { try { return JSON.parse(cfg.text()); } catch (e) { return null; } } // --- socket ----------------------------------------------------------- property bool warned: false property bool wasOpen: false WebSocket { id: sock onStatusChanged: { if (status === WebSocket.Open) { root.wasOpen = true; return; } if (status === WebSocket.Connecting) return; // QML's WebSocket hides the close code, so a wrong password // (4009) and OBS quitting look the same. Say it once per run. if (root.wasOpen && !root.connected && !root.warned) { console.warn("obs: closed before Identified, wrong password?"); root.warned = true; } root.reset(); } onTextMessageReceived: message => { try { root.receive(JSON.parse(message)); } catch (e) { console.warn(`obs: bad message: ${e}`); } } } function reset() { connected = false; wasOpen = false; recording = false; paused = false; duration = 0; scenes = []; scene = ""; camId = -1; camOn = false; } // Retry while not connected; a closed or failed socket is reopened by // toggling active. Timer { interval: 3000 repeat: true running: !root.connected triggeredOnStart: true onTriggered: { if (sock.status === WebSocket.Open || sock.status === WebSocket.Connecting) return; const c = root.config(); if (!c || !c.server_enabled) return; sock.active = false; sock.url = `ws://127.0.0.1:${c.server_port}`; sock.active = true; } } // --- protocol ----------------------------------------------------------- property int seq: 0 function send(op, d) { sock.sendTextMessage(JSON.stringify({ op: op, d: d })); } function request(type, data) { if (!connected) return; send(6, { requestType: type, requestId: String(++seq), requestData: data || {} }); } function identify(token) { const d = { rpcVersion: 1, eventSubscriptions: 4 | 64 | 128 }; // Scenes | Outputs | SceneItems if (token) d.authentication = token; send(1, d); } Process { id: auth command: ["sh", `${Quickshell.shellDir}/island/obs-auth.sh`] stdinEnabled: true property string data: "" onStarted: { auth.write(auth.data); auth.stdinEnabled = false; } stdout: StdioCollector { onStreamFinished: root.identify(text.trim()) } } function receive(m) { switch (m.op) { case 0: { // Hello const a = m.d.authentication; if (!a) { identify(""); break; } const c = config(); auth.data = `${c ? c.server_password : ""}${a.salt}\n${a.challenge}\n`; auth.stdinEnabled = true; auth.running = false; auth.running = true; break; } case 2: // Identified connected = true; warned = false; request("GetRecordStatus"); request("GetSceneList"); break; case 5: event(m.d.eventType, m.d.eventData || {}); break; case 7: response(m.d); break; } } function event(type, d) { switch (type) { case "RecordStateChanged": recording = d.outputActive; paused = d.outputState === "OBS_WEBSOCKET_OUTPUT_PAUSED"; if (!recording) duration = 0; break; case "CurrentProgramSceneChanged": scene = d.sceneName; request("GetSceneItemList", { sceneName: scene }); break; case "SceneListChanged": case "SceneItemCreated": case "SceneItemRemoved": request("GetSceneList"); break; case "SceneItemEnableStateChanged": if (d.sceneName === scene && d.sceneItemId === camId) camOn = d.sceneItemEnabled; break; } } function response(d) { if (!d.requestStatus.result) { console.warn(`obs: ${d.requestType} failed: ${d.requestStatus.comment ?? d.requestStatus.code}`); return; } const r = d.responseData || {}; switch (d.requestType) { case "GetRecordStatus": recording = r.outputActive; paused = r.outputPaused; duration = r.outputDuration; break; case "GetSceneList": // obs-websocket lists scenes bottom-up; reversed is OBS's own order. scenes = r.scenes.slice().reverse().map(s => s.sceneName); scene = r.currentProgramSceneName; request("GetSceneItemList", { sceneName: scene }); break; case "GetSceneItemList": { const it = r.sceneItems.find(i => i.inputKind === "v4l2_input"); camId = it ? it.sceneItemId : -1; camOn = !!it && it.sceneItemEnabled; break; } } } // OBS owns the clock, so pausing freezes it with no local bookkeeping. // ponytail: one local request a second, only while recording. Timer { interval: 1000 repeat: true running: root.recording && !root.paused onTriggered: root.request("GetRecordStatus") } } ``` - [ ] **Step 2: Commit** `island: Obs singleton, obs-websocket v5 client`. Verified together with the pill in Task 5. ### Task 4: `ObsPill.qml` **Files:** Create `desktop/island/ObsPill.qml` - [ ] **Step 1: Write it** (GPL header, then): ```qml import QtQuick import ".." import "Logic.js" as Logic // Record and elapsed, pause, scene, webcam. Each part hides when it has // nothing to do: pause while not recording, the scene with fewer than two, // the webcam when the scene has none. Capsule { id: pill restWidth: row.implicitWidth + 24 Row { id: row anchors.centerIn: parent spacing: 12 // Dot and time share one click target. Item { anchors.verticalCenter: parent.verticalCenter implicitWidth: rec.implicitWidth implicitHeight: rec.implicitHeight Row { id: rec spacing: 6 Text { anchors.verticalCenter: parent.verticalCenter text: "\uf111" color: Obs.recording ? Theme.red : Theme.subtext opacity: Obs.paused ? 0.5 : 1 font { family: Theme.iconFamily; pixelSize: 14 } } Text { anchors.verticalCenter: parent.verticalCenter visible: Obs.recording text: Logic.obsElapsed(Obs.duration) color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Obs.toggleRecord() } } Text { visible: Obs.recording anchors.verticalCenter: parent.verticalCenter text: Obs.paused ? "\uf04b" : "\uf04c" color: Theme.text font { family: Theme.iconFamily; pixelSize: 14 } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Obs.togglePause() } } Text { visible: Obs.scenes.length > 1 anchors.verticalCenter: parent.verticalCenter text: Obs.scene color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } // One step per wheel notch, as the status pill's layout flag. MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor property real acc: 0 onClicked: Obs.setScene(Logic.obsStep(Obs.scenes, Obs.scene, 1)) onWheel: wheel => { acc += wheel.angleDelta.y; if (Math.abs(acc) >= 120) { Obs.setScene(Logic.obsStep(Obs.scenes, Obs.scene, acc > 0 ? -1 : 1)); acc = 0; } wheel.accepted = true; } } } Text { visible: Obs.camId >= 0 anchors.verticalCenter: parent.verticalCenter text: Obs.camOn ? "\u{F0567}" : "\u{F0568}" color: Obs.camOn ? Theme.text : Theme.overlay font { family: Theme.iconFamily; pixelSize: 16 } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Obs.toggleCam() } } } } ``` - [ ] **Step 2: Commit** `island: OBS pill`. ### Task 5: Wire into the island **Files:** Modify `desktop/island/Island.qml` - [ ] **Step 1: Hover.** Line `readonly property bool hovered: ...` gains `|| obs.hovered`. - [ ] **Step 2: Mask.** In `mask: Region { ... }` add `Region { item: obs.visible ? obs : null; intersection: Intersection.Combine }`. - [ ] **Step 3: Pill**, after `MediaPill`, mirroring it on the right: ```qml // While OBS runs; right of the status pill, following it as the sound // panel widens it. ObsPill { id: obs visible: Obs.connected x: win.cx - width / 2 + (win.gap * 1.5 + status.width + width / 2) * win.reveal y: -height + (win.top + height) * win.reveal opacity: Math.min(1, win.reveal * 2) } ``` - [ ] **Step 4: Sliver pulse**, right after the `sliver` Row: ```qml // Recording: a red pulse over the sliver, steady and dim while paused. // Red alone would not say it: OBS's screen source already lights the // privacy sliver's red segment whenever OBS is open. Rectangle { x: sliver.x y: 0 width: sliver.width height: sliver.height radius: 2 color: Theme.red visible: Obs.recording property real pulse: 1 opacity: Obs.paused ? 0.4 : pulse SequentialAnimation on pulse { running: Obs.recording && !Obs.paused loops: Animation.Infinite NumberAnimation { to: 0.25; duration: 700; easing.type: Easing.InOutSine } NumberAnimation { to: 1; duration: 700; easing.type: Easing.InOutSine } } } ``` - [ ] **Step 5: Smoke.** Ask the user to run `./reload.sh desktop` (new files in `island/` need the reindex). Then `qs log -p desktop --log-times | tail -40`: expect no `ReferenceError`, no `QtWebSockets` import error. - [ ] **Step 6: Live check with OBS** (user starts OBS; the server is enabled). Ask the user to confirm by eye: 1. Pill appears within ~3s of OBS starting, disappears on quit. 2. Click record: dot turns red, timer counts, sliver pulses. 3. Pause: timer freezes, sliver steady dim. Resume continues. 4. Scene: click steps, wheel steps both ways, OBS follows; changing scene in OBS updates the pill. 5. Webcam icon toggles the source in OBS; toggling the eye in OBS updates the icon. 6. Stop: pill back to idle, pulse gone. Agent side: `qs log -p desktop | grep obs:` shows no warnings during the above. - [ ] **Step 7: Commit** `island: place the OBS pill and pulse the sliver while recording`. ### Task 6: Docs **Files:** `desktop/island/README.md`, `AGENTS.md` - [ ] **Step 1: README**, new section after "Media pill": ```markdown ## OBS pill Right of the status pill, while OBS Studio runs with its WebSocket server enabled (Tools > WebSocket Server Settings; auth stays on, the server listens on every interface). `Obs.qml` speaks obs-websocket v5 directly and reads the password from OBS's own `obs-websocket/config.json`, so no copy of it exists; `obs-auth.sh` computes the SHA256 handshake QML cannot. Record dot and elapsed time (click starts or stops), pause (while recording), the current scene (click or wheel steps, wrapping; only with two or more scenes), and the webcam (the first `v4l2_input` item in the current scene; click shows or hides it). While recording the sliver pulses red, and holds dim while paused. QML's `WebSocket` does not expose the close code, so a wrong password reads the same as OBS quitting; it is logged once as "closed before Identified". ``` - [ ] **Step 2: AGENTS.md** line 19: `island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy, calendar` → append `, OBS`. - [ ] **Step 3: Commit** `docs: the OBS pill`.