# Omapomodoro NDJSON protocol Every stdout line is one JSON object. UTF-8, no pretty-print. `v` is the protocol version. v1 clients reject `v !== 1`. Unknown `type` values are ignored (forward compatible). stderr is human logs only, **except** the daemon's first stderr line which is a JSON `hello`. State lives under `$XDG_STATE_HOME/omapomodoro` or `~/.local/state/omapomodoro` (`0700` dirs, `0600` files). The control socket is `$XDG_RUNTIME_DIR/omapomodoro/omapomodoro.sock`. ## Commands ``` omapomodoro-track proto|ensure|daemon|status|view|stop|start|pause|resume|skip|reset|config|goal|clock ``` `--state-dir` and `--socket` override the default paths (tests). Options (omitted = do not change): `--focus-min` `--short-break-min` `--long-break-min` `--long-every` `--auto-start-next <0|1>` `--keep-days` `--goal-pomos` `--goal-min` `--notifications <0|1>` `--sound <0|1>` `--test-clock`. `clock` takes `--advance-ms N` or `--suspend-ms N` (requires `--state-dir` and a daemon spawned with `--test-clock`). There is no `--now-ms`. Exit codes: `0` ok, `1` usage, `2` io/lock. `-h` / `--help` prints usage on stderr and exits `1`. Unknown commands exit `1`. `--test-clock` / `--advance-ms` / `--suspend-ms` without `--state-dir` exit `1`. `proto` creates the state dir (mode `0700`) and prints one `proto` event. It does not start a daemon or bind the socket. `ensure` starts `daemon` if the socket is down (child loads `config.json` first, then applies only flags present on argv). If the socket is up, `ensure` sends `config` / `goal` only when those flags are present. Omitted flags never reset last-seen values. Always prints one `status`. `status` / `view` talk to the daemon when it is up, otherwise they print the last `snapshot.json` (or a synthesized idle event) with `daemon: false`. `start` / `pause` / `resume` / `skip` / `reset` / `config` / `goal` / `clock` require a live daemon. If the socket is down they exit `2` with `{"type":"error","message":"daemon down","fatal":true}` and do not mutate offline. `stop` is idempotent. Shutdown is **not** abandon: `stop`, `SIGINT`, and `SIGTERM` recompute remaining, flush `session.json`, persist dirty history **without** closing the open phase, write a last snapshot, and unlink the socket and pid. Next `ensure` reloads the envelope. `--test-clock` on `daemon` / `ensure` spawn installs `TestClock`. `clock --advance-ms N` adds N to wall+boot+mono. `clock --suspend-ms N` adds N to wall+boot, not mono. Against a daemon without `--test-clock` those lines return `{"type":"error","message":"test clock off","fatal":false}` and exit `2`. ## Socket request lines One line, no JSON on the request side: ``` ping status view stop start pause resume skip reset config focus=25 short=5 long=15 every=4 auto=0 keep=31 notify=1 sound=1 goal pomos=8 min=0 advance 1500000 suspend 3600000 ``` `config` and `goal` include only keys that were set. `stop` takes the flush path, not abandon. ## Events ### `proto` ```json {"v":1,"type":"proto","protocol":1,"stateDir":"/home/postman/.local/state/omapomodoro","socket":"/run/user/1000/omapomodoro/omapomodoro.sock","pid":4242} ``` ### `hello` First stderr line of `daemon` (the one JSON-on-stderr exception). ```json {"v":1,"type":"hello","pid":4242,"stateDir":"/home/postman/.local/state/omapomodoro","startedAt":1755417600} ``` ### `status` ```json { "v": 1, "type": "status", "todayKey": "2026-08-17", "phase": "focus", "running": true, "paused": false, "armed": false, "userPaused": false, "remainingMs": 1452000, "plannedMs": 1500000, "remainingLabel": "24:12", "chipLabel": "24:12", "cycleIndex": 2, "longBreakEvery": 4, "focusCompleted": 3, "focusMs": 4500000, "label": "3", "daemon": true, "locked": false, "sessionIdle": false } ``` Field rules: - `phase` ∈ `idle` | `focus` | `shortBreak` | `longBreak` - `armed` is a boolean. Idle chip (`phase==idle && !armed`) uses today's count. Running, paused, and armed use `chipLabel === remainingLabel`. - Do **not** ship a field named `idle`. Session loginctl IdleHint is `sessionIdle` (informational; does not pause). `LockedHint` pauses focus. - `label` is always today's completed count. ### `view` Superset of `status` plus analytics. Snapshot on disk is a `view` so both chip and overlay can watch one file (chip ignores unknown fields). `src/protocol.rs` `VIEW_ONLY_KEYS` are: `endsAt`, `startedAt`, `autoStartNext`, `focusAbandoned`, `breakMs`, `longBreakMs`, `completionRate`, `words`, `sessions`, `week`, `heatmap`, `insights`, `goals`, `config`. Later revisions add keys; they do not rename. ```json { "v": 1, "type": "view", "todayKey": "2026-08-17", "phase": "focus", "running": true, "paused": false, "armed": false, "userPaused": false, "remainingMs": 1452000, "plannedMs": 1500000, "remainingLabel": "24:12", "chipLabel": "24:12", "endsAt": 1755419100000, "startedAt": 1755417600000, "cycleIndex": 2, "longBreakEvery": 4, "autoStartNext": false, "focusCompleted": 3, "focusAbandoned": 1, "focusMs": 4620000, "breakMs": 600000, "longBreakMs": 0, "completionRate": 75, "label": "3", "words": "3 POMODOROS", "daemon": true, "locked": false, "sessionIdle": false, "sessions": [ { "id": "live", "kind": "focus", "startedAt": 1755417600000, "endedAt": 0, "plannedMs": 1500000, "elapsedMs": 48000, "outcome": "running", "dayKey": "2026-08-17", "label": "48s", "startedLabel": "12:00", "caption": "" }, { "id": "01JABC", "kind": "focus", "startedAt": 1755414000000, "endedAt": 1755415500000, "plannedMs": 1500000, "elapsedMs": 1500000, "outcome": "completed", "dayKey": "2026-08-17", "label": "25m", "startedLabel": "11:00", "caption": "" } ], "week": [ { "key": "2026-08-11", "label": "Tue", "focusCompleted": 0, "focusMs": 0, "met": false, "isToday": false, "isWeekend": false } ], "insights": [ { "label": "Best day", "value": "Thu · 10" }, { "label": "Avg / day", "value": "4 / day" }, { "label": "Focus minutes", "value": "11h 40m" }, { "label": "Completion", "value": "75%" } ], "goals": { "dailyPomos": 8, "dailyMin": 0, "todayPomos": 3, "todayMs": 4620000, "met": false, "streak": 5, "best": 12, "weekHits": 4, "weekMisses": 2, "label": "3", "target": "8" }, "heatmap": [ { "key": "2026-07-18", "focusCompleted": 6, "focusMs": 9000000, "met": false, "frac": 0.75, "isToday": false } ], "config": { "focusMin": 25, "shortBreakMin": 5, "longBreakMin": 15, "longBreakEvery": 4, "autoStartNext": false, "keepDays": 31, "notifications": true, "sound": true } } ``` Array / object rules (from `src/stats.rs`, `src/streaks.rs`, `src/daemon.rs`): - `sessions` — live row first when a running or paused session exists (not idle or armed), then closed rows newest first. Closed list is capped at 40 (`MAX_SESSION_ROWS`). Live `outcome` is `running` or `paused`; closed is `completed` / `abandoned` / `skipped`. `kind` ∈ `focus` | `shortBreak` | `longBreak`. `caption` is `"started yesterday"` when `dayKey` is not `todayKey`, else `""`. Overlay drops a `view` with `sessions.length > 80`. - `week` — trailing 7 local days, oldest first. Keys: `key`, `label`, `focusCompleted`, `focusMs`, `met`, `isToday`, `isWeekend`. - `heatmap` — `keepDays` cells, oldest first, today last. Keys: `key`, `focusCompleted`, `focusMs`, `met`, `frac` (0–1), `isToday`. Overlay drops a `view` with `heatmap.length > 400`. - `insights` — four `{label,value}` rows: Best day, Avg / day, Focus minutes, Completion. - `goals` — `dailyPomos`, `dailyMin`, `todayPomos`, `todayMs`, `met`, `streak`, `best`, `weekHits`, `weekMisses`, `label`, `target`. Both goals 0 ⇒ tracking off (`streak`/`best`/`weekHits`/`weekMisses` = 0, `met` false). Incomplete today is neither a week hit nor a miss. - `config` — `focusMin`, `shortBreakMin`, `longBreakMin`, `longBreakEvery`, `autoStartNext`, `keepDays`, `notifications`, `sound`. - `words` is `"1 POMODORO"` or `"N POMODOROS"`. `completionRate` is `100 * focusCompleted / (focusCompleted + focusAbandoned)` (0 if none). ### `error` ```json {"v":1,"type":"error","message":"daemon down","fatal":true} ``` ## Persistence ``` ~/.local/state/omapomodoro/ # or $XDG_STATE_HOME/omapomodoro history.json # { v, days: { YYYY-MM-DD: Day } } snapshot.json # last view event (bar + overlay watch this) session.json # open-phase envelope config.json # last-seen durations / flags goals.json # { v, dailyPomos, dailyMin } daemon.lock / daemon.pid $XDG_RUNTIME_DIR/omapomodoro/omapomodoro.sock ``` Atomic publish: write `*.tmp`, rename. History and session are `fsync`'d; snapshots are relaxed. Days older than `keepDays` (default 31, clamp 7–365) are pruned on persist.