npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zeph-to/cli

v3.6.2

Published

Zeph CLI + push notification SDK for AI agents

Readme

@zeph-to/cli

npm downloads node license docs

Your agent works, hits a decision, and asks your phone. You tap a button (or type a reply), and the answer lands back in the live session — so the agent keeps going.

@zeph-to/cli is the terminal side of that round trip: a zero-dependency push SDK, a zeph CLI that wires up 8 AI agents in one command, and a resident listener that lets your phone drive any agent session it can start by typing straight into named tmux sessions.

  • ZephHook SDK — native fetch, no runtime deps. Send / list / dismiss pushes.
  • zeph CLI — one-command setup for 8 agents, push sending, and the resident listener for phone-driven remote control.

Part of the Zeph toolchain: @zeph-to/mcp-server (the MCP tools your agent calls, e.g. zeph_ask) · zeph-to/plugin (Claude Code plugin bundling hooks + MCP + rules) · the Zeph app on your phone.

New here? docs.zeph.to walks the whole setup — one command on this machine, the app on your phone, and a restart. The reference below assumes that is already done.

Quick Start

Install the CLI globally, then run setup:

npm install -g @zeph-to/cli
zeph install

zeph install opens a browser sign-in on a fresh machine — the web app issues an API key + hook as a matched pair and the CLI writes them to ~/.zeph/config.json (no copy-paste). Then it installs rules + hooks + MCP for every detected agent (Claude Code / Cursor / Windsurf / Gemini / Codex / …). Safe to re-run: a saved login is reused untouched (no prompts), so a re-run just refreshes the agent integrations. To switch account, zeph install --relogin forces a fresh sign-in.

Why global, not npx? zeph cc (drive a session from your phone) needs zeph on your PATH, and the agent hooks this installs are $(command -v zeph || npx …) — a global binary skips an npx cold-start on every notification. For notifications only, with no phone control, npx @zeph-to/cli install is a lighter alternative that skips the global binary.

Once installed, the hooks fire in every session of each configured agent — zeph cc is the phone-control bridge, not the notification switch. In Claude Code the routine per-turn push starts off (quiet is the default) — while you're at the terminal; once you've stepped away, a finished turn still pushes (Quiet while away). /zeph-mode normal turns it on for a project, /zeph-mode loud pushes on every turn, /zeph-mode mute silences a project entirely, and /zeph-mode shows what's in effect. See Mute & push mode.

~/.zeph/config.json is the single source of truth — the CLI, the MCP server, the plugin hooks, and the listener all read it. You never need ZEPH_API_KEY-style env vars for a normal setup; they exist as overrides (second account, CI).

Variants:

# Headless box (no browser): paste credentials from another machine's
# ~/.zeph/config.json
zeph install --key ak_... --hook hook_...

# Skip the interactive agent picker
zeph install --only claude,cursor

# Switch account — force a fresh browser sign-in over a saved login
zeph install --relogin

# Refresh credentials only (no agent re-install) — assumes you already
# ran `zeph install` once; on its own it does NOT wire MCP into agents
zeph login

To send notifications:

zeph notify --title "Deploy done" --body "v2.1.0 shipped"

To drive an agent session from your phone, see Remote Control below.

Remote Control

Send messages from your phone into a live agent session — even after a zeph_ask polling window has expired.

The MCP tool zeph_ask waits on a fixed timeout (120–600 s). Once that window closes the session becomes unaddressable from the phone, even though it's still running. The zeph listener daemon fixes this by keeping a persistent WebSocket open to Zeph and injecting matching messages into a named tmux session via tmux send-keys.

Architecture

[phone — "Active Agents" picker on Zeph app]
   │  selects session, types message
   ▼  POST /pushes/send  { type: 'agent.command',
   │                       agentSessionName: 'zeph-myapp',
   │                       body: '리팩토링 마무리해줘' }
[Zeph backend]
   │  WebSocket fan-out (push.new)
   ▼
[zeph listener — resident daemon, started by `zeph cc` automatically]
   │  tmux send-keys -l -t zeph-myapp "리팩토링 마무리해줘" + Enter
   ▼
[tmux session "zeph-myapp" running claude / codex / cursor-agent / gemini / hermes]

The listener polls its tmux session inventory every 5 seconds and reports it to the server whenever something changed (new session, agent state transition, activity) — an unchanged inventory is re-sent only as a 30-second idle heartbeat. The phone picker stays in sync with no manual configuration, and an idle listener costs the backend a fraction of what a fixed 5-second report cycle would.

Live timeline

While a session is working, the phone can draw what the agent is doing right now — the prompt that started the turn, each tool call and its outcome, the agent's own replies, and per-message token counts. It is read by tailing the agent's transcript on disk; nothing a tool read or wrote leaves the machine, only names, short labels and numbers.

Each agent stores its transcript differently, so a row in REMOTE_AGENT_TABLE (src/remote-agents.ts) names two things: where the file is, and which projector reads it. An agent with neither answers no_transcript and the phone says it has no live timeline.

| Agent | Transcript | Projector | |-------|-----------|-----------| | Claude Code | ~/.claude/projects/<encoded cwd>/<uuid>.jsonl | src/transcript-tail.ts | | pi | ~/.pi/agent/sessions/<encoded cwd>/<ts>_<id>.jsonl (PI_CODING_AGENT_DIR) | src/pi-transcript.ts | | Codex CLI | ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl (CODEX_HOME) | src/codex-transcript.ts | | Cursor, Gemini, Hermes, OpenCode | — none yet | — |

Adding an agent means a projector file of its own, not another branch in an existing one: the formats share no vocabulary. Each projector is pinned to the vendor layout it was read against, and an entry type it has never seen draws no row rather than breaking the watch.

Remote-origin detection (sticky REMOTE mode)

A message injected via send-keys is indistinguishable from typing — so the listener also records each injection as a one-shot marker (epoch + sha256 of the text, keyed by the pane's project dir). A prompt-submit hook on the agent side matches the submitted prompt against that marker and, on an exact match, tells the model the user is driving the session from their phone — entering sticky REMOTE mode (every response ends with an answerable zeph_ask).

| Agent | Hook | Installed by | |-------|------|--------------| | Claude Code | UserPromptSubmit → plugin's zeph-remote.sh | Zeph plugin | | Gemini CLI | BeforeAgent → zeph remote-hook gemini | zeph setup | | Codex CLI | UserPromptSubmit → zeph remote-hook codex | zeph setup | | Pi | before_agent_start → zeph remote-hook pi (via extension) | zeph setup | | Cursor CLI | — none yet | — |

Subagents (view-only). A subagent shows up on your phone as a child of its parent card, named <session>.<n> — it streams live, but input, keys, exit, resume and forget are all refused: you watch from the phone and answer at the terminal, where the parent owns the conversation. A prompt push from a subagent names it and opens its stream directly.

They reach the roster two ways. A pi subagent spawned into an extra pane of the same tmux window is found as a pane, and labelled from @zeph_pane_label (the pi extension sets it from PI_SUBAGENT_NAME). A Claude Code subagent — the Agent tool — has no pane at all; it is found in the parent's transcript directory instead (src/subagent-transcripts.ts), labelled from the description the parent gave it, and carries the number of tools it has run so far. Both kinds are numbered in one sequence, so the names never collide.

A Claude Code subagent is marked inProcess, because the phone cannot mirror a pane that does not exist — its terminal view draws the transcript instead. It leaves the roster the moment the parent's transcript records its end (a background subagent's task notification, or a foreground one's Agent call returning), rather than after five quiet minutes; one that is resumed comes back when it writes again.

Detection is exact-match: a terminal keystroke racing a phone message can never false-flag. Muted projects are never flagged.

Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider, OpenCode) have one other way in: a zeph_ask answer reporting zephState: "REMOTE". Their rule files therefore keep the after-real-work zeph_ask — the "Entering REMOTE without a prompt hook" preamble in src/templates.ts — so the phone always has a button to tap that starts the loop. The one exception is an answer sent with the phone's "send and exit": that turn carries out the instruction and ends without an ask. Agents with the hook drop that obligation while at the terminal — and their rule file carries only the NORMAL branch of the core (about a quarter of it), since it is read on every turn; the hook injects the REMOTE sections in full on the turn a phone message enters REMOTE, the way the Claude Code plugin's hooks do.

zeph cursor has no remote-origin hook, so it never enters sticky REMOTE mode by itself. Just ask for it — one line, once per session:

I'm driving this session from my phone. End every response with zeph_ask so I can answer with a button.

Nothing else is missing: injection works, and the MCP tools are all there, zeph_ask included. Check them with cursor-agent mcp list-tools zeph — mcp list prints only the approved list, not what's configured, so it reads as empty even when the server is wired up.

Why it isn't automatic: hooks.json is honored by the Cursor IDE but not by cursor-agent, and beforeSubmitPrompt's output schema is {continue, user_message} — no context channel to deliver a marker match through. Same reason the stop-hook auto-push zeph setup installs covers the Cursor IDE but not zeph cursor panes; ask for a zeph_notify when you want one.

zeph hermes is in the same position, and asking for it works the same way. zeph install does not wire Hermes either — add the MCP server to ~/.hermes/config.yaml by hand, as one entry under mcp_servers:

mcp_servers:
  zeph:
    command: "zeph"
    args: ["mcp"]

zeph mcp runs the MCP server inside the CLI process. The older command: "npx" / args: ["-y", "@zeph-to/mcp-server"] form still works, but leaves an npm exec launcher resident beside the server for as long as the session lives — zeph verify flags a config still on it.

Add the entry; do not replace the file. mcp_servers is shared with every other server you have configured, and nothing under ~/.hermes is in version control. Then /reload-mcp inside Hermes.

zeph_ask needs a hook id, which the MCP server reads from ZEPH_HOOK_ID or from hookId in ~/.zeph/config.json — zeph install writes the latter, so a machine that has run it once needs no env block here.

Setup

  1. Install tmux. The listener uses send-keys; the wrapper spawns named sessions. zeph install checks for it and, on macOS with Homebrew, offers to run brew install tmux for you. Elsewhere it prints the line to run — sudo apt install tmux on Debian/Ubuntu, and so on — because installing with sudo is not something an installer should do on your behalf. tmux has no Windows build, so zeph cc needs WSL there.

  2. Add wsUrl to ~/.zeph/config.json (the WebSocket endpoint of your Zeph backend — CDK output WsApiUrl). Keep it on the same stage as baseUrl: the REST stage is the path prefix (/v1 prod, /d1 dev) and the socket stage is the hostname, so they can disagree without either looking wrong. zeph verify says which stage each one is on, and the listener only reads this at startup — zeph listener --restart after editing:

    {
      "apiKey": "ak_...",
      "hookId": "hook_...",
      "wsUrl": "wss://<api-id>.execute-api.<region>.amazonaws.com/<stage>"
    }

    This is where it belongs. --ws-url overrides it for a single run. ZEPH_WS_URL is deprecated: it used to outrank this file, which meant an export in a shell profile silently chose the stage and left no trace of having done so. It now loses to the file and is read only when the file has no wsUrl, so nothing that worked stops working — but it will go, and zeph verify says so while it is still set.

  3. Run agents through the wrapper. That's it.

    zeph cc        # claude       → tmux session "zeph-<project>"
    zeph codex     # codex        → tmux session "zeph-<project>"
    zeph cursor    # cursor-agent → tmux session "zeph-<project>"
    zeph gemini    # gemini       → tmux session "zeph-<project>"
    zeph hermes    # hermes       → tmux session "zeph-<project>"
    zeph pi        # pi           → tmux session "zeph-<project>"
    zeph opencode  # opencode     → tmux session "zeph-<project>"

    Two wrapper flags, read off the front of the args: --detach creates the session without attaching (no TTY needed — a script or another agent's pane can launch it) and prints its name; --label <x> names it zeph-<project>-<x> instead of the -2/-3 family. Example — a Claude Code planner starting a pi implementer for one plan:

    zeph pi --detach --label 20260914-PLAN-11-32-44-pi -n 20260914-PLAN-11-32-44 '/skill:02-implement .claude/20260914/PLAN-11-32-44.md'

    A labelled session, and any session started in a linked git worktree, still shows on the phone under its project — the main checkout's name — with the label (or the worktree's session name) beside it. The wrapper records both as tmux session options (@zeph_project, @zeph_session_label) because the session name alone cannot say where the project ends. A session started without them (before this, or by an older CLI) is grouped by the listener from its pane's checkout instead, so upgrading the listener is enough — no session restart.

    Inside herdr. herdr finds agents by a pane's foreground process, which for a wrapped session is the tmux client, so the agent would not show in its sidebar. Every zeph attach records the herdr pane it runs in as session options (@zeph_herdr_pane, @zeph_herdr_socket, @zeph_herdr_bin), and clears them when attached from outside herdr; a bare tmux attach changes neither, so the agent keeps reporting to the last herdr pane zeph saw. Reporters read them on every report and send the agent's working / idle / blocked state to that pane: Claude Code through the Zeph plugin's zeph-herdr.sh hook (plugin 0.20.0+) as zeph cc, pi through the Zeph pi extension as zeph pi. herdr's own pi extension reads the pane from its env, which tmux does not pass, so it stays silent in a wrapped session. Headless runs (claude -p, pi -p, RPC) started from a shell in a wrapped pane inherit its TMUX_PANE and never report.

    A shell the phone may type into — zeph sh. Every other shell pane is refused (see Defense); zeph sh opens the one exception, zeph-<project>-sh running your login shell, for the jobs an agent's permission prompt blocks — a restart, a kill, a sudo. It is a real terminal: cd and export stick, sudo/ssh prompts work, and a long command does not tie up an agent. --label <x> names it zeph-<project>-<x>; --detach creates it without attaching. Away from the desk, open one from the phone through any running Claude Code session by sending !zeph sh --detach (Claude Code's ! bash mode — see Defense item 7). Inside tmux it never attaches; it prints a tmux switch-client -t <session> line instead. Running it again for a session it opened reattaches, and a session of that name it did not open is refused.

    zeph sh              # → tmux session "zeph-<project>-sh"
    zeph sh --detach     # create it, print its name, exit

    The mirror fits a detached session to the phone. A pane is as wide as the terminal that last drew it, so a desktop's prompt rules and right-aligned clocks fold in two on a phone. While the phone watches a session no terminal is attached to (a zeph sh --detach shell, or an agent whose terminal you closed), the listener resizes its window to the phone's width and gives the old width back when the phone leaves. An attached session keeps its size: shrinking it would shrink your desktop terminal, so the phone wraps those rows instead. A window split into panes is never resized, and one a terminal attaches to while the phone watches is handed back within a renew (5 s). A listener that died while a window was fitted restores it the next time it starts.

    zeph cursor runs cursor-agent, Cursor's terminal agent — a separate install from the Cursor IDE (the bare cursor on your PATH is the editor launcher, which exits immediately and can't be driven).

    zeph hermes runs Hermes in its classic REPL, which is the default. Launching it in TUI mode instead (zeph hermes --tui, or HERMES_TUI=1 in the environment) puts the pane on the terminal's alternate screen, and the alternate screen has no scrollback — the live mirror on your phone still draws, but scrolling up in it comes back empty.

    If a Hermes session disappears from the phone's picker, the cause is tmux having dropped the pane's start_command: all the listener sees then is the interpreter Hermes execs (python3.11 at time of writing), which names no agent. zeph listener run in the foreground prints the reason (no agent in pane (start=…, current=python3.11)). Kill the session and start it again with zeph hermes to recover. claude sits on the same edge for the same reason — it execs node.

    The first zeph cc on a machine auto-spawns a background listener (singleton, PID file at ~/.zeph/listener.pid, stdout/stderr at ~/.zeph/listener.log). You never run zeph listener by hand — every zeph cc checks the PID file and skips the spawn when one is already alive, so opening a dozen terminals doesn't create a dozen daemons. The daemon survives between zeph cc invocations — but not a reboot, which is what the login-time service below is for.

    Project name resolves from CLAUDE_PROJECT_DIR / CURSOR_PROJECT_DIR / WINDSURF_PROJECT_DIR if set, else the git repo root, else the cwd basename. Any extra args after the command pass through to the agent verbatim:

    zeph cc --resume "abc123"
    zeph cc --dangerously-skip-permissions
    zeph codex --model gpt-5-high "fix the failing test"

    Multiple sessions in one project. Open another terminal in the same folder, run zeph cc again, and the wrapper auto-suffixes: first session is zeph-encl, the next attached one becomes zeph-encl-2, then zeph-encl-3, etc. The phone picker shows them as encl · Claude, encl · Claude #2, encl · Claude #3.

    Reattaching wins over spawning. If any session of that project is detached (no one attached), the wrapper reattaches instead of spawning a new one — close the terminal, come back later, pick up where you left off. When several are detached it takes the highest-numbered one first, then works down on later runs, so no session can end up out of reach. That matters because a session you cannot reach still holds a live agent: an agent CLI plus its MCP servers is a few hundred MB you can neither see nor reclaim. A detached session is never killed for you — nothing here can tell "done with this" from "closed the laptop".

    Only a slot running the same agent is reattached. zeph pi will not reattach a detached zeph cc session: tmux attaches to a session that already exists and drops the command it was given, so you would land in claude with pi never started. A slot running another agent is skipped and the next suffix is used instead.

    If you're already inside a tmux session ($TMUX set) the wrapper skips the outer tmux and runs the agent in the current pane — the listener can't target an unnamed session that way, but you keep your existing multiplexer setup.

Starting the listener at login (macOS)

A reboot ends the daemon, and nothing starts it again until you open a terminal and run zeph cc. Until you do, the phone shows no agents — the machine is on, the past sessions are on disk, and the app has no one to hear from.

zeph listener --install-service

That registers a launchd LaunchAgent (to.zeph.listener) that starts the listener at every login. --uninstall-service removes it and --service-status shows what the installed one points at; zeph install offers it during setup and zeph uninstall takes it away again.

What you get back after a reboot is your past sessions, ready to resume from the phone — not live agents. tmux does not survive a reboot either, and this doesn't try to bring those sessions back to life.

Two details worth knowing:

  • It fires at user login, not at boot. Without automatic login, the machine stays silent while it sits on the login screen.
  • The plist bakes in absolute paths for node and the CLI, a PATH that can reach tmux, and a UTF-8 LANG. launchd gives a job /usr/bin:/bin:/usr/sbin:/sbin and no locale at all: without the PATH the daemon exits 127 at every login, and without the locale tmux escapes the separator the session list is split on, so every session is silently dropped while everything else looks healthy. If any of that drifts — a version-manager node upgrade, say — zeph verify names it, and re-running --install-service repoints the plist.

With the service installed, launchd owns the process: zeph cc and zeph listener --stop|--restart ask launchd rather than signalling the PID, so there is never a second daemon racing the first.

Keeping the Mac awake

The listener holds an outbound socket, and macOS "Wake for network access" does not protect it — that setting only wakes the machine for inbound requests to services it advertises. When the Mac idle-sleeps, the socket dies and the phone shows "Waiting for the live stream…" until someone opens the lid. A Mac running Claude Code hides this (the agent runs caffeinate during every turn), so one machine looks fine while another, idling at a prompt, keeps dropping.

So on macOS the listener runs caffeinate -s -w <its pid> for as long as it lives. -s is honoured on AC power only — on battery macOS ignores it, so a laptop still sleeps and nothing drains. The display still turns off; a closed lid still sleeps. Activity Monitor shows the daemon under "Preventing Sleep". Opt out with --no-keep-awake or "keepAwake": false in ~/.zeph/config.json; the log's keep-awake: line says which way it went.

Diagnostics

The auto-spawned listener writes to three files under ~/.zeph/:

  • listener.pid — the running daemon's PID. cat ~/.zeph/listener.pid
    • ps -p <pid> to confirm it's alive.
  • listener.version — the CLI version the daemon booted from. This is what zeph cc compares against the installed package to spot a stale daemon (see below).
  • listener.log — stdout + stderr from the daemon. tail -f to watch.

The daemon logs its version on the first line, which is the only reliable way to tell which build a long-running process is on:

[xx:xx:xx] zeph listener starting — v1.26.0 — wss://ws.zeph.to

A healthy listener log shows one line per cycle:

[xx:xx:xx] reported 2 session(s): zeph-myapp, zeph-otherapp
[xx:xx:xx] ✓ server persisted 2 session(s)

If you see ! server rejected listener.sessions: ... instead, the message points at the failure (auth, missing device record, etc.) so you can fix the actual problem instead of guessing.

To force a restart:

zeph listener --restart

After upgrading @zeph-to/cli you normally don't have to. npm i -g replaces the package on disk but never the daemon already running from the old build — that daemon keeps answering pushes (so agent chat looks fine) while silently ignoring every message subtype added since it booted. zeph cc compares listener.version against the installed version and restarts the daemon for you when the installed one is newer (a daemon newer than the zeph cc you ran is left alone, so several installs on one machine don't fight over it):

zeph: listener 1.25.0 is stale — restarting on 1.26.0

If the PID file is missing (a different account started the daemon, or it was removed by hand) the singleton guard can't see it. Find the real process instead:

ps aux | grep '[c]li.js listener'

To run it in the foreground (for development of the SDK itself):

zeph listener

You'll get the same logs you'd otherwise tail from listener.log.

Custom tmux sockets

The listener auto-discovers the tmux socket — it probes the default socket first and stops there when it answers. Otherwise it probes every socket file in the per-user tmux-<uid>/ dirs ($TMUX_TMPDIR/$TMPDIR, macOS /var/folders/.../T/, /tmp/), which is where tmux -L <name> puts its socket. Only when none answers does it ask lsof which socket each running tmux process holds — the slow path, which also covers a -S path outside those dirs. If your tmux uses tmux -L <name> or a non-standard -S <path>, set the override explicitly:

export ZEPH_TMUX_SOCKET=/path/to/socket

(The wrapper passes the env to the auto-spawned listener, so setting it in your shell rc is enough.)

Wire format

The listener only acts on pushes with type='agent.command' carrying the tmux session name in agentSessionName and the message in body. Other pushes (Stop-hook auto-pushes, zeph_ask responses, channel broadcasts, plain notes) are ignored. End-to-end:

tmux send-keys -l -t <agentSessionName> "<body>"
tmux send-keys    -t <agentSessionName> Enter

If you need to send one from the command line (debugging, scripting), build the structured push directly:

curl -X POST "$ZEPH_BASE_URL/pushes/send" \
  -H "X-API-Key: $ZEPH_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "agent.command",
    "targetDeviceId": "dev_listener_<sha8(hostname)>",
    "agentSessionName": "zeph-myapp",
    "body": "테스트 통과시키고 PR 올려줘"
  }'

Defense

The listener is a remote-code-execution surface by design (it types into a shell-adjacent pane). The defense is layered:

  1. Pane guard — before injecting, the listener checks the pinned pane's #{pane_current_command}. If the pane is at an interactive shell (bash/zsh/fish/sh/dash/ksh/tcsh/csh/pwsh), the inject is refused. CC/Codex/Gemini exited ≠ phone gets free shell access. The one exception is a zeph sh session: its @zeph_shell session option names the session itself. Only a process on this machine can set a tmux option, and a value inherited from set -g names no session. The marker says you opened that shell; it is not a boundary — see what it does not stop, below.
  2. Addressed shells only — a marked shell takes a REST agent.command only when its targetDeviceId is this machine. An agent session still takes an unaddressed one, which push.new fans out to every listener of the account; for a shell, two PCs with zeph sh open in the same project would both run it. Stream input already arrives on one listener's own subscription.
  3. Nothing a shell would misread — into a marked shell the listener refuses attachments (their paths would arrive as lines, each a command), control bytes such as ESC (which can leave a bracketed paste), and a newline in an insert (a shell without bracketed paste, such as macOS /bin/bash 3.2, would run it). A multi-line body is allowed: each line is a command you meant.
  4. Audit — every input admitted into a shell session is one line in $XDG_STATE_HOME/zeph/shell-audit.log (default ~/.local/state/zeph/shell-audit.log, mode 0600; past 1 MiB it moves to shell-audit.log.1, replacing the one before): time, session, pane, kind, foreground program, and the text only when the shell itself was in front. Input to anything else — a sudo or ssh password prompt — is recorded as [N chars → "sudo"]. listener.log, which is not private, shows only the length. The foreground is judged when the input lands, so a secret read by the shell itself (read -s) is logged in full, and a command typed inside sudo -s or ssh is logged by length only.
  5. Literal injection — text is delivered as a paste of data, not typed key names; tmux escape sequences inside a message can't drive other tmux commands.
  6. Session-name allowlist — only [A-Za-z0-9._-]+ is accepted as a session target, so shell metacharacters never reach the tmux argv. Agents skip a shell: zeph send and zeph_agent_send (from @zeph-to/mcp-server 3.3.0) drop agentKind: "shell" sessions from their candidates. The check runs on the sending machine only, so an older zeph on any machine of your account can still type into your shell — update every machine that runs agents before you open one.
  7. Per-session rate limit — a token bucket caps a runaway or compromised sender, shell sessions included.
  8. Agent permission gate — with one hole. Your CC/Codex/Gemini permission prompt still stands in front of every tool call the agent makes. But Claude Code's ! prefix runs a shell command directly, as typed input, with no permission prompt, and text the phone injects is typed input: !<command> sent to a Claude Code session runs on this machine. This is accepted, and it is how !zeph sh --detach opens a shell from the phone.

What the marker does not stop: anything running as your user on this machine can set @zeph_shell on a session, or run zeph sh itself — and so can the phone, while a Claude Code session (!tmux set-option …) or a marked shell is open to it. It keeps the phone from choosing panes only when neither is. And no layer authenticates the sender: whoever holds your API key, or controls the backend, can type into every marked shell and, through !, every Claude Code session.

The transport (WS) is authenticated by API key + push:read scope. Whether your backend also reads what crosses it depends on encryption being on:

  • Encryption off — the default. The phone has no device keypair to hand the listener, so pane frames and the messages you type both cross the relay in plaintext.
  • Encryption on. The phone sends its device public key when it subscribes, every pane frame comes back inside an ECDH P-256 + AES-256-GCM envelope, and your keystrokes are sealed for this listener with their seq/epoch stamps embedded in the ciphertext, so the relay can't replay one. A frame that fails to encrypt is dropped, never downgraded.

Two gaps either way. A message sent while no live stream is open falls back to REST, which is plaintext to the server — that includes every @<session> command from the agent chat, and every command typed into a zeph sh shell. With encryption off, a shell's screen crosses the relay in plaintext too, so keep secrets off both. And the sealed channel buys confidentiality against a passive relay only: each side learns the other's key from the wire, so a backend that mints its own keypair can pose as the listener.

CLI Usage

# Send a notification
zeph notify --title "Deploy done" --body "v2.1.0 shipped"

# Send with priority
zeph notify --title "Build failed" --priority high --url https://ci.example.com/123

# List recent pushes
zeph list
zeph list --limit 10 --type note

# Dismiss a push
zeph dismiss push_01JXY...
zeph dismiss --all

# Rename the current agent session (shows in the app's Agents list)
zeph rename "Prod deploy"
zeph rename --clear                # reset to the default name

# Type a message into another agent session, here or on another PC
zeph send dev_listener_ab12cd34:zeph-api "Build is green — rerun the e2e suite"
zeph send zeph-api "…"             # a name/alias works when it names one session
zeph send zeph-api - <<'EOF'       # a long message on stdin; the quoted heredoc expands nothing
Build is green. `npm test` passed.
EOF

# Drop an ended session from this machine's record (it leaves the app's past list)
zeph forget zeph-myapp-20260914-PLAN-11-32-44-pi
zeph forget --ended                # every session tmux no longer has

# Test connection
zeph test

# Run an agent in a named tmux session (so the listener can reach it)
zeph cc                       # claude
zeph codex                    # codex
zeph cursor                   # cursor-agent (Cursor CLI, not the IDE)
zeph gemini                   # gemini
zeph hermes                   # hermes

# Run the resident listener (foreground; background it as you like)
zeph listener
zeph listener --ws-url wss://...   # override config

# JSON output
zeph notify --title "Hello" --json

Commands

| Command | Description | |---------|-------------| | login | Browser sign-in: auto-fetch API key + hook into ~/.zeph/config.json over a localhost loopback (--web-url, --timeout). No copy-paste | | install (alias: setup) | One-command setup: detect agents, save config, install rules + hooks + MCP. No saved config → opens browser login automatically. --only claude,cursor,… skips the picker | | uninstall | Remove Zeph from all detected agents (--dry-run, --purge) | | verify | Check installation health across detected agents (--ping for a live API call). Also reports the resolved WebSocket URL and where it came from — --ws-url, config, the deprecated ZEPH_WS_URL, or the built-in default — warns when the socket and the API base are on different stages, and warns when the running listener is on a different URL than the one that resolves now (it reads the config once, at startup) | | check-update | Check whether a newer Zeph version is on npm | | notify | Send a push notification | | list | List recent push notifications | | dismiss <id> | Dismiss a push (or --all) | | rename <name> | Set the current agent session's display name in the app — run inside a zeph cc session (--clear resets). Auto-detects the tmux session + this machine's listener device id, so the alias lands on the right device | | send <target> <message…> | Type a message into another agent session — on this machine or another — as if the user sent it from the phone; it shows in that session's chat and puts it in remote mode. target is the <deviceId>:<session> key or a name/alias that names exactly one session (a name two machines share is refused, with both keys listed, and so is a session on a machine that is offline — its listener would never type it). The receiver reads [from <your alias or tmux name>@<machine> · reply: <your key>] first; outside tmux it is [from cli@<host>], and from a tmux session the listener does not report, or from a subagent, it is [from <session name>@<host>] — neither carries a reply key. The CLI twin of the MCP zeph_agent_send, for agents without MCP (pi). Global flags (--key, --base-url) go before send; everything after the target is the message, verbatim, and a --key or --base-url placed there is refused (exit 2) rather than typed into the other session. A - as the message reads it from stdin: write it on a quoted heredoc (<<'EOF'), since inside double quotes the shell would run backticks or $(…) in it. The body is plaintext even with E2E on — keep secrets out. Exit 0 sent, 2 usage, 1 failed, 3 no API key | | forget <session> | Drop an ended session from this machine's record, so it leaves the app's past list (and the resume whitelist — the same thing deleting the row on the phone does). Refuses a session that is still running: kill it first. --ended forgets every remembered session tmux no longer has, which is what a machine that launches agents under a fresh name per task eventually needs | | test | Verify connection and API key | | cc · codex · gemini | Run the agent in a zeph-<project> tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes -2, -3, …. Auto-spawns the background listener on first invocation so the phone picker just works. Trailing args pass through to the agent (zeph cc --resume "..."). On node 22.15+ the wrapper hands its process to tmux rather than waiting on it, so a running session shows no zeph cc of its own in ps — older runtimes and Windows keep the waiting wrapper --detach / --label <x> (front of the args) create without attaching / pin the name — see "Run agents through the wrapper" above. | | sh | Open (or reattach) zeph-<project>-sh, a login shell the phone may type into — the one shell pane the listener does not refuse. --label <x> / --detach as for the agents; inside tmux it never attaches and prints a switch-client hint. Refuses a session of that name it did not open. Input must be addressed to this machine, is audited, and agents (send, zeph_agent_send) skip it — see Defense | | mcp | Run the MCP server on stdio, in this process. What agent MCP configs launch — zeph install registers it and you never type it. Replaces the old npx -y @zeph-to/mcp-server registration, which left an npm exec launcher resident alongside the server for the life of the session. @zeph-to/mcp-server ships as a dependency now, so it updates with the CLI rather than being re-fetched by npx on every launch — zeph check-update reports the version you actually have | | listener | (Usually unnecessary — zeph cc autospawns it.) Resident daemon: subscribes via WebSocket, reports tmux session inventory every 5 s, injects agent.command pushes into the matching session. Run in the foreground for SDK development; otherwise let zeph cc manage it |

Notify Options

| Flag | Description | |------|-------------| | --title <text> | Push title (default: "Task done") | | --body <text> | Push body (default: "<project> · <branch>" if cwd is a git repo, else "<project>") | | --url <url> | URL to include | | --type <type> | Push type: note, link, file, hook | | --priority <p> | Priority: low, normal, high, urgent | | --device <id> | Target device ID | | --session <id> | AI session ID so the push threads into that session's chat (or ZEPH_SESSION_ID env) | | --auto | Apply the push gate before sending — honors the /zeph-mode quiet / /zeph-mode loud push-mode dial, per project or machine-wide (--global); gated-out exits silently with code 0 | | --pushmode-default <m> | Mode --auto assumes when the project has no dial: quiet (built-in), normal, loud. A dial the user set always wins | | --marker <m> | Push Signal marker for --auto: skip, push, high | | --tools <n>, --nonreadonly <n> | Turn tool counts feeding --auto's heuristic (defaults assume real work) |

The defaults are tuned for hook-driven invocations (e.g. Stop hooks calling zeph notify --title "Task done" without a body) — you'll see which project + branch finished without writing per-IDE wrappers. Pass --body "" explicitly to suppress.

Listener Options

| Flag | Description | |------|-------------| | --ws-url <url> | WebSocket endpoint for this run only; the lasting setting is wsUrl in ~/.zeph/config.json | | --key <api-key> | API key (or set ZEPH_API_KEY env) | | --base-url <url> | REST API base URL (or set ZEPH_BASE_URL env, or baseUrl in ~/.zeph/config.json) | | --stop | Stop the running daemon and clear its PID/version stamps | | --restart | Stop it and relaunch detached (logs to ~/.zeph/listener.log) | | --no-keep-awake | Don't hold the Mac awake on AC power (macOS). Lasting form: "keepAwake": false in ~/.zeph/config.json |

The listener reconnects with exponential backoff + jitter (1 s → 30 s cap). It pings every 25 s and drops the socket after 30 s of total silence — any inbound frame counts, ack or pong. That 30 s is waived while the daemon's own event loop is blocked, because a thread that is not listening cannot tell a dead socket from its own deafness; 90 s of wall-clock silence terminates the socket either way. On an authentication failure close (4001/4002/4003) the listener exits with code 3 instead of looping forever — fix the key and restart.

Local transfer. Once its device keys are loaded the listener also opens a small HTTP server on an OS-assigned port and publishes host:port (private IPv4 only) on its device record, so a sender on the same Wi-Fi can hand a file straight to this machine instead of going through the cloud. The log says local transfer: listening on 0.0.0.0:<port>. If the macOS firewall is on, the first bind may ask "Do you want the application node to accept incoming network connections?" — declining is safe; files simply keep taking the relay.

Receiving files. A file push addressed to this machine (zeph_file, zeph notify --file, the phone's share sheet) is saved to ~/Downloads/Zeph/ under its own name — name (2).ext when that name is taken, never overwritten, never cleaned up — and a desktop banner (Zeph · <file> over the push's title and the folder, e.g. [myproject] shot.png · ~/Downloads/Zeph) says where it landed. The name in the banner is the one it was saved under, so a (2) shows up there. With terminal-notifier installed (brew install terminal-notifier), clicking the banner reveals the file in Finder; the plain macOS banner has no click action. Encrypted files are opened with this machine's own device key on the way down; nothing is buffered, so a 1 GB recording streams straight to disk. agent.command attachments are unaffected: they still go to ~/.zeph/attachments/<pushId>/ and are swept after 24 h.

A sender on the same network can skip the cloud entirely: it pings this machine's endpoint, then uploads the encrypted file straight to it. Every request is authenticated with a key only a device registered on your account can derive (ECDH with this machine's device key), so an unknown sender — or a replayed or tampered upload — gets an empty 401 and nothing is written. File names and the recipient list travel sealed too; someone sniffing the Wi-Fi sees a device id and a transfer id, nothing else. The sender counts a file delivered only on a receipt this machine alone can compute, so a host that merely answers on this machine's address cannot swallow it — the file goes via the cloud instead. A device removed from the account is refused within a minute. The file waits in ~/.zeph/attachments/lan/ until its push record arrives, then moves to ~/Downloads/Zeph/ like any other; a transfer whose record never comes is swept after 24 h. Two uploads at a time, 1 GB each at most.

List Options

| Flag | Description | |------|-------------| | --limit <n> | Number of pushes (1-20, default 5) | | --type <type> | Filter by push type |

Global Options

| Flag | Description | |------|-------------| | --key <api-key> | API key (or set ZEPH_API_KEY env) | | --base-url <url> | API base URL (or set ZEPH_BASE_URL env) | | --json | Output JSON format | | --version | Print version |

Mute & push mode

Both live as state files under ${XDG_STATE_HOME:-~/.local/state}/zeph, keyed by a cksum hash of the project directory. Claude Code's /zeph-mode mute / /zeph-mode quiet / /zeph-mode loud / /zeph-mode normal write them; the CLI reads them (mute on every notify, push mode on --auto).

Notifications are silently skipped when a mute file exists for the current project:

STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/zeph"
HASH=$(printf '%s' "$PROJECT_DIR" | cksum | cut -d' ' -f1)

# Mute (created by /zeph-mode mute in the Claude Code plugin)
mkdir -p "$STATE_DIR" && touch "$STATE_DIR/muted-$HASH"

# Unmute
rm -f "$STATE_DIR/muted-$HASH"

Push mode is a one-word file (quiet / loud / normal) resolved in this order — first hit wins:

| Order | File | Set by | |-------|------|--------| | 1 | $STATE_DIR/pushmode-<hash> | /zeph-mode quiet · /zeph-mode loud · /zeph-mode normal | | 2 | /tmp/zeph-pushmode-<hash> | older versions (honored only when you own the file) | | 3 | $STATE_DIR/pushmode-default | the --global form of any dial — the machine-wide default | | 4 | --pushmode-default <mode> | the calling hook (the installed ones pass normal) | | 5 | (nothing above) | quiet |

Row 5 changed: an install with no dial anywhere used to be normal. It is now quiet, so upgrading turns the routine per-turn push off until you run /zeph-mode normal. Row 4 is why the hooks this CLI installs are unaffected: they name normal themselves, since a hook that supplies no high marker would be silent under quiet whenever you're at the terminal, rather than merely quieter. Row 4 sits below the state files on purpose — the flag names a default, it does not override a dial the user set.

Under normal the JSON hook configs push on every turn, because they see no per-tool events and so pass no counts — the gate then assumes real work. The Pi extension and the OpenCode plugin do see those events, so they pass real --tools / --nonreadonly counts and go quiet where the others cannot: a turn with no tool calls, a turn with exactly one (a lone edit included — the gate wants two), and a turn whose calls were all reads. /zeph-mode loud still pushes on all of them.

The Pi extension also pushes when pi is waiting on you: any blocking extension dialog — a bash guard's Run/Abort, an ask-user tool, a ctx.ui.confirm — that is still open 10 seconds after it appeared sends a high push ("pi asks: ", body = the dialog title or the guarded bash command). high gets through quiet; only /zeph-mode mute stops it. Answering inside the 10 seconds sends nothing. It is the pi twin of the Claude Code plugin's AskUserQuestion push, driven by pi's ui_prompt_start / ui_prompt_end events. Only dialogs that open while a turn is running count: a settings picker you open yourself between turns (/caveman config) never pushes.

Two more end-of-turn cases differ from Claude Code:

  • Push Signal markers — pi's rules teach the same <!-- zeph: skip|push|high --> markers, and the extension reads them from the final assistant message (then strips them, since pi's terminal would otherwise print the comment). high is how pi gets a "the turn ended with a question for you" push through quiet.
  • Errors and aborts — a turn that ended on a provider error sends a high "pi stopped: " push with the error text instead of "Task done"; a turn you aborted with Esc sends nothing.

Quiet while away

quiet silences routine pushes because you are watching the pane. When you've walked away, nobody is, so notify --auto under quiet still sends the push (normal priority) once it decides you're away. It checks three signals, and the first that answers wins:

  1. Inside tmux and no client attached to the tmux server → away.
  2. Not over SSH and macOS ioreg reports HIDIdleTime (system-wide input idle) → that alone decides. Inside tmux, "over SSH" follows the session environment tmux refreshes on each attach, not the value frozen at server start.
  3. Inside tmux → the newest tmux client_activity (SSH and non-macOS).

Anything unreadable counts as present. The threshold is ZEPH_AWAY_SEC seconds in the hook's environment (default 300, 0 turns detection off, and a non-number falls back to the default). The probe runs only on a quiet turn with no high marker. The Claude Code plugin's Stop hook applies the same rule (zeph_is_away in its hooks/gate.sh).

A dial file that exists but reads empty resolves to normal, not to row 5: an empty file is a failed write, and resolving breakage to silence leaves no symptom to debug.

Mute has no -default form on purpose: it is keyed on presence, not content, so a global mute could never be lifted for a single project.

Legacy /tmp/zeph-muted-<hash> files are still honored when owned by the current user (the state dir moved out of world-writable /tmp).

The CLI checks CLAUDE_PROJECT_DIR, CURSOR_PROJECT_DIR, WINDSURF_PROJECT_DIR, and falls back to cwd.

Exit Codes

| Code | Meaning | |------|---------| | 0 | Success | | 1 | General error | | 2 | Quota exceeded | | 3 | Authentication failed (also: listener auth close 4001/4002/4003) | | 127 | A required external binary (e.g. tmux, claude) was not found on PATH |

Environment Variables

| Variable | Description | |----------|-------------| | ZEPH_API_KEY | API key (fallback when --key not provided) | | ZEPH_BASE_URL | API base URL (default: https://api.zeph.to/v1) | | ZEPH_WS_URL | Deprecated. WebSocket endpoint for zeph listener. wsUrl in ~/.zeph/config.json wins over it; read only when the file has none. It will stop being read | | ZEPH_TMUX_SOCKET | Explicit tmux socket path for the listener (skips auto-discovery — use when your tmux runs with -L <name> or a custom -S <path>) | | ZEPH_SESSION_ID | AI session ID (fallback when --session not provided) |

SDK Usage

import { ZephHook } from '@zeph-to/cli';

const hook = new ZephHook({ apiKey: 'ak_...' });

// Notify
const result = await hook.notify({
  title: 'Build Complete',
  body: 'Deploy succeeded',
  url: 'https://example.com/deploy/123',
  priority: 'high',
});
console.log(result.pushId); // 'push_01JXY...'

// List
const list = await hook.list({ limit: 5 });
console.log(list.pushes);

// Dismiss
await hook.dismiss('push_01JXY...');
await hook.dismissAll();

Constructor Options

| Field | Type | Description | |-------|------|-------------| | apiKey | string | Required — API key from Zeph settings | | baseUrl | string? | API base URL (default: https://api.zeph.to/v1) | | timeout | number? | Request timeout in ms (default: 30000) |

Notify Payload

| Field | Type | Description | |-------|------|-------------| | title | string? | Push title | | body | string? | Push body | | url | string? | URL to include | | type | 'note' \| 'link' \| 'file' \| 'hook'? | Push type (default: hook) | | priority | 'low' \| 'normal' \| 'high' \| 'urgent'? | Priority (default: normal) | | targetDeviceId | string? | Send to specific device |

Error Handling

import { ZephHook, AuthenticationError, QuotaExceededError, ZephError } from '@zeph-to/cli';

try {
  await hook.notify({ title: 'Hello' });
} catch (err) {
  if (err instanceof AuthenticationError) { /* Invalid API key */ }
  if (err instanceof QuotaExceededError) { /* Monthly limit reached */ }
  if (err instanceof ZephError) { /* Other API error */ }
}

Supported Agents

zeph install detects and configures these agents automatically:

| Agent | What gets installed | |-------|-------------------| | Claude Code | Plugin (hooks + MCP server) | | Cursor | MCP server + stop hook + rules | | Windsurf | MCP server + response hook + rules | | Gemini CLI | MCP server + AfterAgent hook | | Codex CLI | MCP server + prompt/stop hooks + rules | | Copilot CLI | Session end hook + rules | | Cline | Rules file (~/.cline/rules/zeph.md) | | Aider | Conventions file + read: directive in ~/.aider.conf.yml | | Pi | Extension (settle hook + waiting-on-you push + remote detection) + rules — no MCP (pi has none; rules map zeph tools to the CLI) | | OpenCode | MCP server + idle-notify plugin + rules |

For remote-control via zeph listener the per-agent setup is the same across CC/Codex/Gemini — the wrapper just spawns them in a named tmux session.

Encryption

End-to-end encryption is off by default and turning it on needs Zeph Pro. The switch is in the app under Settings → E2E Encryption; until you flip it, every push leaves this host in plaintext. If the account later loses Pro the server answers PRO_REQUIRED and the CLI resends the same push in the clear. No configuration either way.

With it on, push bodies and long-body attachments are encrypted with AES-256-GCM. This host holds its own ECDH P-256 keypair in ~/.zeph/device-keys.json — generated on first use, and the private half never leaves the machine; the backend stores public keys only and rejects a private-key upload. Each push is encrypted once, and its AES key is wrapped separately for every device on your account using ECDH against that device's public key. The title and URL move inside the ciphertext with the body.

Threat model: against a passive backend — a leaked snapshot, an operator reading the table — the stored ciphertext and wrapped keys are useless, so push contents stay private. Three limits worth knowing:

  • No protection from an active malicious operator. Recipient public keys come from GET /devices on that same server, unsigned and unpinned. A backend that injects a device record carrying its own key gets the message key wrapped for it, and reads everything. The Zeph app ships the counter-measure — compare device fingerprints, mark a device verified, and strict mode then wraps only for verified devices — but it defaults off, its verified list is per browser profile, and this CLI does not consult it: selectRecipients asks only whether a device has a public key, and whether that key is the legacy account-wide one (ADR-0007 Phase 4).
  • No forward secrecy. The ECDH secret for a given sender/device pair is static, so compromising either private key opens every past push wrapped for that pair.
  • senderPublicKey is unsigned, so a swapped one makes a push undecryptable — that direction fails closed rather than leaking.

A device that has not registered a per-device public key cannot be sent to; it is skipped, and if no device qualifies the push goes out in the clear rather than arriving as something nothing can open.

This host becomes a device others can encrypt for once zeph listener runs: it registers the public key on this machine's device record each time it connects to Zeph.

The zeph listener opens encrypted file pushes addressed to it, and ignores every other isEncrypted push — it does not try to decrypt them. Stop-hook auto-pushes and zeph_ask responses are not part of the @<session> injection path, so this doesn't affect normal use.

Requirements

  • Node.js >= 18 (uses native fetch).
  • tmux — required for zeph cc / codex / gemini and zeph listener.
  • The ZephHook SDK has no runtime dependencies. The CLI depends on @inquirer/prompts for the interactive zeph install picker and on ws for the listener's WebSocket subscription.

License

Apache-2.0