# Gateway, MCP and local API Branch `claude/gateway-core-da93b5`. This covers the packages the main session owned in [WORKING.md](../WORKING.md): `internal/gateway` (except the shared `api.go`), `internal/policy`, `internal/approval`, `internal/history`, `internal/mcp` and `cmd/pinky`, plus `internal/api` (the local socket, listed in DESIGN's repository layout), `internal/demo` (scripted fake phones) and, from round three, `internal/recording`. The first round used fake phones only; the second ran the gateway on the Mi 10 Pro and the iPhone (see "Real phones") and added the Settings API; the third ([round3.md](round3.md)) added waking and unlocking, `phone_files`, one stream per device, recordings, retention and export, background displays and MCP over HTTP (see "Round three"). ## What works Everything below runs and is tested against fake phones; real drivers plug in later (see "Integration"). | Area | What it does | | --- | --- | | Gateway | `gateway.New(Options{Providers, DataDir, …})` discovers devices every 2 s through providers (no platform branches), opens them, and implements `gateway.API` for the client. Agents use `Session` methods instead, which are not in `API` | | Pipeline | lease and scope → policy → approval → execute → settle (`driver.Settler` when the driver has it, else polling `Observe` until two reads match, 3 s limit) → read and diff → record. One `screen.Refs` per device; results are `screen.Diff` lines, or the whole screen on an app change | | Leases | One MCP session can hold several phones. A lease ends on `phone_release`, session close, 10 idle minutes, Stop, or after 60 s offline. Another session gets `lease_held` with the holder's agent and task. A drop mid-action returns `uncertain`, then `device_offline`; a phone back within 60 s keeps its lease | | Person controls | `TakeOver`, `HandBack`, `Stop`, `StopAll`, mirror input (`Touch`, `Key`, `Text`; taps and swipes are emulated when a driver lacks `Toucher`), lock state (from `Screen.Locked` and `driver.LockReporter`; an idle phone every `idleLockEvery`, 6 s, or `idleLockEveryWiFi`, 30 s, over Wi-Fi with no picture watched, GitHub issue #96), client events | | Policy | Allow, Ask, Deny; commit detection (`commit`, `payment`, `send_message`, `install_app`, `open_url`, `call`, plus `change_setting`) with word tables in 11 languages; YAML rules, strictest wins; a bundled, editable app table; blocked apps hidden from agents; password and one-time-code fields refused (rule `credentials`); read-only mode; per-task app list; one-time codes masked on the screen and in events, and covered in screenshots | | Approvals | 60 s from opening, then Deny; calls block 45 s, then `approval_pending` with a single-use id bound to session, device, lease, tool and arguments; decisions between calls are kept; on Allow the target and screen text are checked again (`stale`); the phone takes no other action meanwhile (reads still work); only the client's `Decide` answers | | History | SQLite through `modernc.org/sqlite` with the tables from DESIGN (devices, agents, sessions, tasks, steps, approvals, media) plus `recordings` and `device_settings`; a JPEG before and after each step in `media/`, taken in the background (see "What the agent waits for"); a screen recording per task in `recordings/`; retention, export and `WipeHistory` | | MCP | The 20 tools from DESIGN on the official Go SDK v1.8.0, with progress notifications when a request has a `progressToken`, over stdio (through the socket) and Streamable HTTP. Reference: [docs/mcp.md](../mcp.md); agent guide: [docs/SKILL.md](../SKILL.md) | | Local API | A Unix socket only the user can reach (0700 folder, 0600 socket, peer uid checked), one gateway per data folder (flock on `pinky.lock` in the data folder, so two gateways never share one history and the same phones, even when their runtime folders differ: logind removes `/run/user/` at logout; a socket that still answers is left alone, so a gateway on other data in the same runtime folder, or an older Pinky that kept its lock there, keeps it); on Linux the socket is in `$XDG_RUNTIME_DIR/pinky`, and a Pinky started without that variable, by a service for one, uses the user's own `/run/user//pinky` before `~/.local/state/pinky`, so it finds the desktop session's gateway; on Windows a named pipe `\\.\pipe\pinky-` instead (MSIX-packaged apps such as the Claude and Codex desktop apps see a virtualized AppData and could not reach a socket file there, issue #69): owned by the user and open only to them, remote clients refused, the first instance exclusive so a second gateway cannot start beside it (Windows has no flock), clients check the pipe's owner before they send the agent token, and fall back to the old `pinky.sock` to reach an older gateway that is still running; a Unix socket path longer than the system takes (107 bytes on Linux, 103 on macOS) fails with an error that names `PINKY_HOME`; JSON-RPC control plus MCP sessions | | Commands | `pinky` (the app: gateway and window in one process), `pinky daemon` (`--http addr` for remote agents), `pinky mcp` (stdio shim that starts the gateway when none runs), `pinky devices`, `pinky token`, `--fake`; hidden helpers `pinky needs` and `pinky decide` (answers only on a fake gateway) | ## Round three Built on 2026-11-07 from [round3.md](round3.md). Each part has tests against fake phones (test doubles for the driver interfaces the drivers do not have yet), and the streams and recordings also ran on the emulators and both phones (see "Checked on real streams"). ### Wake and unlock - **Wake** (`wake.go`): before an agent's call reads or acts on the phone's own screen (connect, every action, `phone_observe`, `phone_wait_for`, an approved action, a hand-back), the gateway calls `driver.Waker.Wake`, at most once every 5 s (the shortest screen timeouts are 15 and 30 s). A woken phone that is locked shows its lock screen, and the agent gets `device_locked` and the NeedUnlock card as before. Tasks on a background display never wake the phone. - **Unlock from the computer**: `Options.Unlock` is gone; `Options.PIN func(ctx, deviceID) (string, error)` hands out the saved PIN and the gateway enters it with `driver.Unlocker.Unlock`, only after asking the phone again whether it is locked (a PIN typed into an unlocked phone would land in the app in front), and once: a wrong PIN returns `ErrWrongPIN` and is never retried. It holds the device's action lock meanwhile, counts as input (no screenshot or recording of the PIN pad is kept), and adds a step to the running task: "Unlocked from the computer", or "Unlock from the computer failed: the phone did not accept the saved PIN" (status `wrong_pin`). The log says only whether it worked. `DeviceView.Features.Unlock` is the driver's `Unlocker`. - **cmd/pinky** passes a PIN source that answers only while Settings › Unlock from this computer is on, checks `secret.Store.Has` first (so a phone without a saved PIN never brings up Touch ID), and calls `secret.System().Reveal(ctx, secret.PINAccount(id), "unlock "+name)`. On main, `secret.System()` is still the stub, so unlocking from the computer returns the keychain's `ErrUnsupported` until the platform session lands it. ### phone_files `Session.Files` and the tool (`files.go`): `list`, `pull` and `push` between the phone's Pinky folder (`driver.Files`) and `/files//` (the id made safe for a folder name). Results carry the local folder and the files, each with where it is (`both`, `phone`, `computer`, `differs`, by size and time to the second). Pull and push skip files that are the same on both sides and keep modification times. Push asks the person (rule `Files`, the usual 45 s block and `approval_id`; "Allow for this task" covers later pushes), read-only mode refuses it, and read-only and watch-only agents get `scope_denied`. Paths with `..` are refused before the driver sees them; symbolic links in the local folder are never pushed, a pull goes through a temporary file renamed into place, so a link planted where a file goes is replaced, not written through, and a folder on the way that is a link is refused before anything is made. Every call is a history step. iPhones (no `Files`) return `unsupported`. Docs: [mcp.md](../mcp.md), [SKILL.md](../SKILL.md). `upload` (`upload.go`, GitHub DIYgod/pinky#8) is for agents that cannot write that folder, such as one on another computer over HTTP: it saves one file the agent sends into it, for a push after it, and never touches the phone, so it asks nothing; it follows push's scope and read-only rules. Names are checked more strictly than other paths (no hidden files, nothing Windows or Android's shared storage refuse, no Windows device names); a taken name needs `Replace` unless the bytes are the same; the file goes through a `.pinky-` temporary file renamed into place, so a link where it goes is replaced, and a folder on the way that is a link is refused. `FilesRequest.Body` carries the bytes (at most `MaxUpload`, 200 MB), and `Check` only checks. The MCP layer (`internal/mcp/upload.go`) takes the bytes in `content` (base64, up to 10 MB; HTTP requests may now be 16 MiB, as stdio messages are), or, over HTTP without `content`, checks the upload and hands out a link, `/upload/` on the same listener, that takes one `PUT` of the file without the agent token within 10 minutes; links live on `mcp.Server` and end with their session. The address in the link is the request's host (or a proxy's `X-Forwarded-*`), which `HTTP.ServeHTTP` passes to the tools in the `X-Pinky-Link-Base` header over whatever the agent sent. The step reads "Received “photo.jpg” (1.2 MB) from the agent", which `internal/gwtext/gwtext.go` translates. **Resuming sessions** (`resume.go`). Some clients over HTTP open an MCP session for every call, or for every turn, and never send an id twice (Grok Bot, reported on X); each call then found no phone, and the phone stayed leased to the session before until it idled out. `AgentInfo.Resume`, which `internal/mcp` sets for HTTP sessions, lets a newer session of the same agent name continue a lease its older sessions are not in a call on (`adoptLocked`, from `acquire`, `Connect` and `Release`): the lease, its task, grants and open approvals (`approval.Center.Rebind`) move to the newer session, which sessions are ordered by `Session.seq`. It never goes back, and other agents and local agents never adopt. A Resume session that closes keeps its leases for `Options.ResumeGrace` (2 min) for the next session (`keepLocked`); `Session.CloseNow` and `EndKeptLeases` (turning Remote agents off) and `Gateway.Close` end them at once. A Stop of a Resume agent's task holds for its newer sessions for `stopHold` (10 min, `resumeStops`). The new-agent approval of a Resume session is bound to the agent's name, so the next session can repeat it. Upload links follow `Session.Current`. `internal/mcp` also answers a `GET` without a session id with `405`, and serves clients that send no session id at all (`sessionless.go`, GitHub DIYgod/pinky#10): a `POST` without `Mcp-Session-Id` other than `initialize` goes into a session Pinky opens and keeps per `User-Agent`, initialized with the parameters of that client's newest `initialize` whose session no request ever named (a session that clients claim with an id is never used for others), or `clientInfo` "agent" without one; its id is injected as the client would have sent it, and an id-less `notifications/initialized` is answered without forwarding. Tests: `resume_test.go` in both packages, the MCP one with a hand-written client that opens a session per call (with and without `DELETE`), and one that keeps a `GET` stream open while it calls; `sessionless_test.go` and the HTTP end-to-end test send what #10 shows Grok Bot sending. ### One stream per device A phone gives one stream at a time (the Android driver ends the first when a second opens), and the mirror's viewers and the recorder all want it. `streams.go` keeps one hub per device screen: the first member opens the driver's stream, every packet goes to each member, and the stream closes when the last member leaves. A member that joins late first gets the last configuration and the packets since the last key frame, so the mirror shows the current picture at once. Each viewer has a queue of its own and hands packets out through a channel of 4 as it reads them: a burst of the packets since the last key frame never looks like a backlog to the mirror's player (which skips to the next key frame past 30 queued packets). A viewer that falls more than 2048 packets or 64 MB behind skips to the next key frame. When the phone goes offline or the gateway stops, every member sees the stream end. `Gateway.Stream` (the mirror) and `AgentStream` (a background display) are hubs; the recorder taps them. A stream that can ask the phone for a key frame now (an optional `RequestKeyFrame()` on the driver's stream; nothing in `driver.go` yet) is asked when a recording starts or resumes and when a viewer falls behind; otherwise members wait for the next one. The iOS driver could implement it with its `requestKeyFrame` (PLI), the Android driver with scrcpy's `ResetVideo`. ### Recordings One fragmented MP4 per task, remuxed without re-encoding (`internal/recording`, on `github.com/Eyevinn/mp4ff`): an init segment with the first parameter sets (`avc1` or `hvc1`), then a fragment about every second, one starting early at a key frame, so a crash loses at most the fragment being written and leaves a file that players open. Parameter sets that change later (a rotation) go into the next key frame's sample. Files live in `/recordings//.mp4` with a row in the `recordings` table; `Recording(ctx, taskID)` returns the path, start, length (read from the file, so a crash's file is right too) and a mark per step (`StepView.At` from the recording's start). `recording.Open(ctx, path, from)` plays one back as a `driver.Stream`: from the key frame at or before `from`, the frames up to `from` at once, then paced in real time, through a channel of 8 that waits for the reader. `SetRecording(on)` turns recording of new and running tasks on or off (off by default in the gateway; the app and the daemon apply `Settings.Record`, on by default); a task is recorded once, so turning it off and on again does not overwrite its file. **Nothing of a blocked app, a locked screen or the person's own use is stored.** The gateway knows what is on the screen only from reads, so the recorder holds packets back until a read vouches for them: the packets that arrived before a read started are written when that read shows a recordable screen (unlocked, an app that is known and not blocked, the person not in control), and dropped, with everything held since the last read that did, when it does not. Until a read shows a recordable screen again, packets are dropped, and writing resumes at a key frame (a picture decoded against the wrong reference could show traces of what was dropped). Taking over, a lock the driver reports, an unlock from the computer, the phone going offline and a new stream drop what is held too. Every drop is a point in time: reads that started before it can no longer resume the recording, and packets that arrived before it and come later (a hub replays its last key frame to a member that joins again) are never written; the task's start is the first such point, and its end the last (later packets are not taken, so the person's own use after the task stays out, however late the last read comes). These points, the packets' arrivals and the reads' starts are times from `stamp` (`clock.go`), which never gives the same time twice: `time.Now` on Windows moves only with the system's clock tick (0.3 to 2 ms on the CI runners, up to 16 ms by default), and a picture of the person's own use stamped in the tick the task ended in was taken for the task's. When packets have waited 10 s and no action runs, the recorder reads the screen itself, so it holds little; past 48 MB it drops them. When the task ends it reads once more, except after Stop. Frames are placed by their PTS plus the shortest delay any frame of the stream took to arrive, which is smoother than arrival times and never places a frame after it arrived. Reads can overlap (`phone_observe` during an action, the recorder's own read): a recordable read writes nothing while a read that started before it still runs (in order, that one might have dropped it), a read that shows what must not be recorded drops what is held whenever it ends, and a recordable read older than one that already decided changes nothing. The recorder taps the screen's current device each time it opens the stream (a new one after the phone reconnects, a reopened background display), and its last read, when a task ends, reads that screen itself, waiting at most 15 s for actions in progress. A read that names no app (taken while one app hands over to the next; on the emulator, Settings opening) vouches for nothing, for history screenshots too, since a blocked app may be on its way in. A blocked app that comes and goes between two reads, neither of which sees it, is not caught; payment hand-offs stay in the bank's app until the person is done, so the next read sees them. Agents' own screenshots (`phone_observe { screenshot: true }`) now follow the same rule: none while the phone is locked (its lock screen can show notifications from blocked apps) or when a read names no app. Every screenshot the gateway takes (history, approval cards, agents' own) goes through `shotOf`, which asks a driver that prefers pictures from the video (`driver.FrameShooter`: an iPhone over Wi-Fi, whose screenshot is a PNG of up to 15 MB at about 1 MB/s) for the screen from a frame first (`frameShot`), and takes a screenshot when it has none. The rules above apply to either picture alike. ### Retention and export - `SetRetention(days)` (`retention.go`) deletes tasks that ended more than `days` ago (or, for tasks that never ended, as after a crash, started), with their steps, approvals, screenshots and recordings, and sessions left without tasks; at once and every 24 hours after. Running tasks stay; a screenshot that another task's step still shows stays. 0 keeps everything (the gateway's default; the app and daemon apply `Settings.KeepDays`, 30). `Retention()` reads it. If reading a task's screenshots fails partway, that pass changes nothing: going on would delete the tasks and leave the screenshots the read never reached. - `ExportHistory(ctx, taskIDs, dir)` (`export.go`) writes `pinky-history--