# IPC
The `flow` CLI communicates with the `flowd` daemon over a single Windows named pipe
using newline-delimited JSON. The daemon listens for one client at a time on a
background accept thread, while the main IPC thread processes hook events and
dispatches commands.
## Named-Pipe Protocol
The IPC transport uses the Windows named pipe `\\.\pipe\flow` (configurable via the
`FLOW_PIPE_NAME` environment variable for integration tests). The wire format is
newline-delimited JSON: each message is a single JSON object terminated by `\n`,
serialised by `serde_json`. The encoding and decoding helpers live in
[`src/ipc/message.rs`](../../src/ipc/message.rs).
The pipe uses byte mode (`PIPE_TYPE_BYTE | PIPE_READMODE_BYTE`) with duplex
access. The server accepts one client at a time (sequential, not concurrent).
After processing a request, the server disconnects and waits for the next
connection. The server is created with `FILE_FLAG_FIRST_PIPE_INSTANCE` so that a
second daemon instance fails fast with an address-in-use error instead of
silently replacing the first.
```mermaid
sequenceDiagram
participant CLI as flow CLI
participant Pipe as named pipe
participant Accept as Accept Thread
participant IPC as IPC Thread
participant flow as FlowWM
CLI->>Pipe: CreateFileW (overlapped)
Accept->>Accept: ConnectNamedPipe (blocking)
Accept->>IPC: SetEvent(connected_event)
IPC->>IPC: WaitForMultipleObjects wakes
IPC->>Pipe: read_message() -- blocking ReadFile
Pipe-->>IPC: {"type":"focus_left"}\n
IPC->>flow: dispatch(SocketMessage::FocusLeft)
flow-->>IPC: SocketResponse::Ok
IPC->>Pipe: write_response() -- WriteFile
Pipe-->>CLI: {"status":"ok"}\n
IPC->>Pipe: DisconnectNamedPipe
IPC->>Accept: start_accept() -- next client
```
### Why a Named Pipe
A named pipe was chosen over TCP or Unix-domain sockets for three reasons:
- **Local-only** -- named pipes are inherently local to the machine. No network
exposure, no firewall rules, no port conflicts.
- **Windows-native** -- `CreateNamedPipeW` / `ConnectNamedPipe` are the standard
Win32 IPC primitives. No third-party dependency, no compatibility shim.
- **ACL-capable** -- the pipe can (and will, in a future hardening pass) accept a
`SECURITY_ATTRIBUTES` structure to restrict access to the current user session.
Currently any local process can connect (acceptable for the single-user MVP).
### Client-Side Timeout
The client opens the pipe with `FILE_FLAG_OVERLAPPED` so every read/write is
bounded by a 30-second deadline (`IPC_TIMEOUT` in
[`src/ipc/transport.rs`](../../src/ipc/transport.rs)). If the daemon accepts the
connection but never replies, `CancelIo` fires and the CLI returns a `TimedOut`
error instead of hanging forever. Each overlapped operation uses a Win32 manual-
reset event and `WaitForSingleObject` to await completion or cancellation. This
prevents integration tests from stalling when the daemon is stuck in layout
computation.
### Server-Side Architecture
The server side in [`src/ipc/transport.rs`](../../src/ipc/transport.rs) uses
synchronous (blocking) I/O -- appropriate because the daemon's clients are one-
shot `flow` invocations that always send immediately. The `PipeServer` struct
manages a background accept thread: `start_accept()` spawns a short-lived thread
that blocks in `ConnectNamedPipe`, then signals a Win32 event to wake the main
thread's `WaitForMultipleObjects`. See [threading model](threading-model.md) for
how the connected event coexists with the hook signal in the main loop.
All pipe handles are wrapped in `PipeHandle` (RAII, closes on drop) and a
similar `EventHandle` wrapper manages Win32 event handles, preventing kernel
handle leaks on early returns.
## Message Surface
### SocketMessage (CLI -> Daemon)
Commands are grouped by purpose. All variants use serde's externally-tagged enum
format with a `"type"` field and snake_case variant names.
| Control | `Stop`, `ReloadConfig`, `CheckConfig` | Daemon lifecycle and config |
| Focus | `FocusLeft/Right/Up/Down` | Move focus between windows |
| Swap (per-window) | `SwapLeft/Right/Up/Down` | Swap focused window with neighbour |
| Swap (column) | `SwapColumn { direction }` | Swap entire focused column |
| Semantic move | `MoveWindow { direction }` | Daemon resolves concrete action by state |
| Scroll | `ScrollLeft`, `ScrollRight` | Slide the viewport |
| Column resize | `ExpandColumn`, `ShrinkColumn`, `SetColumnWidth { width_px }` | Adjust column widths |
| Window state | `ToggleFloat`, `ToggleMonocle`, `PlaceAbove`, `Promote`, `CloseWindow` | Per-window operations |
| Workspace | `SwitchWorkspace { workspace_id }`, `SwapWorkspace { workspace_id }`, `MoveWindowToWorkspace { workspace_id }` | niri-style virtual desktops (`Switch`/`Move` implemented; `Swap` stub) |
| Query | `QueryWindowsAll`, `QueryLayoutVirtual`, `QueryLayoutActual`, `QueryState` | Read-only introspection |
| Config mutation | `SetConfigValue { key, value }` | Runtime config change |
| App preferences | `ForgetApp { exe }`, `ForgetAllApps` | Clear per-app learned state |
The `MoveWindow` variant is deliberately semantic: for tiled windows moving
left or right it delegates to `SwapColumn`, but the daemon owns the translation
so keybindings stay stable as floating support lands. Vertical movement
(within-column swap) and floating-window nudging are deferred — only the
tiled left/right path is wired today.
Of the three workspace variants, `SwitchWorkspace` and `MoveWindowToWorkspace`
are fully implemented (see [Workspace Hierarchy](./workspace.md) for the
vertical-packing switch animation). Only `SwapWorkspace` returns
`unimplemented_command` — its protocol shape is locked in, but its animation
model (two workspaces exchanging positions in the packed stack) is still
undecided.
### SocketResponse (Daemon -> CLI)
| `Ok` | -- | Command succeeded |
| `Error` | `message: String` | Command failed |
| `Data` | `payload: serde_json::Value` | Response to a query command |
Responses use a `"status"` tag (`"ok"`, `"error"`, `"data"`), distinct from the
message `"type"` tag. A response-shaped JSON will not parse as a `SocketMessage`
and vice versa -- the tests in [`src/ipc/message.rs`](../../src/ipc/message.rs)
enforce this explicitly.
## The `flow` CLI Client
The CLI ([`src/bin/flow.rs`](../../src/bin/flow.rs)) is built with `clap` and
structured into four top-level command groups. Every dispatch command sends a
single `SocketMessage` and prints a one-line success or error message. The CLI
does no layout computation -- it is a thin transport layer. See
[event pipelines](event-pipelines.md) for what happens on the daemon side after
a command arrives.
### Lifecycle
| `flow start [--config <dir>] [--log-file <path>] [--ahk]` | Spawn `flowd.exe` detached, poll until pipe is ready; with `--ahk`, also launch `flow.ahk` via its shell association |
| `flow stop [--ahk]` | Send `Stop` via named pipe; with `--ahk`, also terminate the AutoHotkey process launched by `start --ahk` |
`flow start` locates `flowd.exe` next to the current executable, spawns it with
`CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` (falling back to plain `spawn()`
under WSL interop), then polls the pipe at 200 ms intervals for up to 5 seconds.
The `--config` flag sets `FLOW_CONFIG_DIR` in the current process before spawning
so the child inherits it; the `--log-file` flag is forwarded as a CLI argument.
The `--ahk` flag launches the user's `flow.ahk` **after** the daemon is ready, so
the first hotkey always reaches a listening daemon. AutoHotkey is resolved via
`ShellExecuteExW` on the `.ahk` file (Windows' shell association handles
interpreter lookup); the launched PID is written to `flow.ahk.pid` in the config
directory so `flow stop --ahk` can terminate exactly that process. `stop --ahk`
runs **after** the daemon has stopped, so a live daemon never loses its
keybindings mid-dispatch.
### Autostart
| `flow enable-autostart [--ahk]` | Create a single `flow.lnk` in `shell:startup` that runs `flow.exe start [--ahk]` |
| `flow disable-autostart` | Remove the `flow.lnk` shortcut (idempotent) |
The autostart shortcut targets `flow.exe start [--ahk]` rather than `flowd.exe`
directly, so login reuses the interactive startup path's pre-flight config check
and double-start guard -- there is no parallel startup code path. The `.lnk` uses
`SW_HIDE` to suppress the brief console window `flow.exe` allocates before it
spawns the detached daemon and exits. `disable-autostart` takes no `--ahk` flag
because there is only ever one shortcut.
### Config
| `flow config init` | Create config dir + write default files (no daemon contact) |
| `flow config reload` | Send `ReloadConfig` to daemon |
| `flow config edit` | Open config dir in `$EDITOR` / `VISUAL` / `notepad.exe` |
| `flow config path` | Print resolved config directory path |
| `flow config check` | Validate config files locally |
All config commands except `reload` operate on local files without contacting the
daemon. The config directory resolution chain is documented in
[config and persistence](config-and-persistence.md).
### Query
| `flow query all` | Send `QueryWindowsAll`, pretty-print JSON response |
### Dispatch
| `flow dispatch focus left/right/up/down` | `FocusLeft/Right/Up/Down` |
| `flow dispatch swap-column left/right` | `SwapColumn` |
| `flow dispatch move-window left/right` | `MoveWindow` |
| `flow dispatch expand-column` | `ExpandColumn` |
| `flow dispatch shrink-column` | `ShrinkColumn` |
| `flow dispatch close-window` | `CloseWindow` |
| `flow dispatch switch-workspace <id>` | `SwitchWorkspace` |
| `flow dispatch swap-workspace <id>` | `SwapWorkspace` (stub) |
| `flow dispatch move-to-workspace <id>` | `MoveWindowToWorkspace` |
The dispatch commands that change layout flow through the daemon's 3-stage
pipeline (mutate, project, animate) as described in
[architecture](architecture.md). The `CloseWindow` command is a special case:
it only queues `WM_CLOSE` and lets the `EVENT_OBJECT_DESTROY` hook handle the
actual layout removal, avoiding a race between the synchronous IPC response
and the asynchronous window destruction.
## Graceful-Shutdown Window Rescue
`flow stop` sends the `Stop` command, the event loop exits, and then -- before the
daemon process tears down -- [`FlowWM::rescue_stranded_windows`]
runs once. Its job is to bring windows that became **physically inaccessible**
during this flow session back onto the screen, without disturbing anything the
user can currently see.
### Why rescue is needed
flow parks non-active workspaces off-screen (one monitor height above or below
the active one) and scrolls windows horizontally across the infinite tiling
canvas. If the daemon simply exited, every window on a non-active workspace --
plus any window scrolled into the off-screen packing region of the active
workspace -- would be left stranded where flow put it: visible to Win32 (so a
future flow run would re-classify them as already-tiled) but unreachable to the
user, who has no way to scroll to a workspace that no longer exists.
### The visibility rule
The rescue pass treats the **operating system as ground truth**, not flow's own
bookkeeping. For each controlled window it calls `GetWindowRect` to find the
window's current rect, then asks: does the window's **visible content** overlap
the active monitor's `work_area`?
- **Yes (visible)** -- the user can see this window right now. Leave it exactly
where it is. The user has almost certainly rearranged things by hand since flow
started; rewinding their work would be hostile.
- **No (stranded)** -- the window is off-screen purely because flow moved it.
Bring it back.
The visible-content rect is the raw `GetWindowRect` rect with the window's
invisible borders stripped (`InvisibleBounds::window_to_visible`). This step is
not cosmetic: `GetWindowRect` includes the ~7px invisible borders Windows 10/11
draw for drop shadows, while the layout engine parks columns flush against the
`work_area` edge in **visible** coordinates. Without the conversion, a window
parked exactly at the edge would bleed a few pixels of invisible border into the
`work_area` and read as "visible" -- so the rescue pass would leave it stranded
in the packing region of the active workspace. Stripping the borders recovers
the true visible rect, which only *touches* the edge and is correctly treated as
stranded. See `roadmap.md` ("GetWindowRect Includes Invisible Borders") for
background.
Touching edges do not count as overlapping, matching Win32 `IntersectRect`
semantics (see `Rect::overlaps`).
```mermaid
flowchart TD
A[For each controlled window] --> B[GetWindowRect]
B --> G[Strip invisible borders to visible-content rect]
G --> C{Visible content overlaps work_area?}
C -- yes: visible --> D[Leave it untouched]
C -- no: stranded --> E[Clamp pre_manage_rect into work_area]
E --> F[SetWindowPos to clamped anchor]
```
### The rescue target: `pre_manage_rect`
Each controlled window remembers the rect it had **when flow first noticed it**
(stored as `pre_manage_rect` at registration time). The rescue moves stranded
windows back to that anchor, then clamps the anchor into the `work_area` in case
the anchor itself is off-screen (e.g. the window came from a now-detached
monitor, or the work area changed because the taskbar moved).
Clamping shrinks-and-shifts rather than discards: a window larger than the work
area collapses to the work area's size; otherwise the original size is preserved
and only the position is nudged on-screen.
### Tiles and floats are unified
There is no special-casing between tiling and floating: every window flow is
actively positioning -- a `Tiling(Active)` or `Floating(Active)` window -- goes
through the same loop. `Minimized`, `Hidden`, and `Ignored` windows never reach
it. flow does not actively place minimized/hidden windows, so their position is
the OS's and the user's responsibility; leaving them alone on shutdown avoids
surprising the user by moving windows they intentionally hid.
### The "pre-this-run" contract
`pre_manage_rect` is the position the window held **before this flow run**, not
before the very first flow run in history. Consequences:
- If the previous flow session was killed **uncleanly** (crash, `taskkill /f`,
Ctrl-C), windows it had tiled remain at their tiled positions. The next run
captures *those* as the new baseline. The desktop self-heals after **one
clean** `flow stop`.
- Minimized/hidden windows are out of scope entirely (see above); float windows
lose any in-run drag history (acceptable for v1), restoring to their
registration-time anchor.
The unclean-shutdown case -- where the daemon process is gone and cannot run the
rescue pass -- is what the planned watchdog process is for: restoring from an
on-disk snapshot without needing the daemon alive (see the
[Roadmap](./roadmap.md)).
### Code path
| Wired after the event loop exits | [`src/main.rs`](../../src/main.rs) -- `flow.run()` then `flow.rescue_stranded_windows()` |
| The rescue loop | [`src/daemon/shutdown.rs`](../../src/daemon/shutdown.rs) |
| Which windows are controlled | `WindowRegistry::restorable_windows` in [`src/registry/core.rs`](../../src/registry/core.rs) |
| Clamp geometry | `Rect::clamped_into` in [`src/common/types.rs`](../../src/common/types.rs) |
## Watchdog
A separate watchdog process is planned for crash recovery — restoring windows
from an on-disk snapshot when the daemon dies unexpectedly. It is not yet
implemented and is deliberately deferred until `flow`/`flowd` are
feature-complete. See the [Roadmap](./roadmap.md) for the full design,
including why it is a separate process rather than a Windows service.
## Cross-References
- [Threading model](threading-model.md) -- the IPC thread's `WaitForMultipleObjects`
loop and how pipe connections coexist with hook events
- [Event pipelines](event-pipelines.md) -- the IPC command dispatch path through
the 3-stage layout pipeline
- [Architecture](architecture.md) -- subsystem overview showing where the IPC
server fits inside the daemon