A small daemon on the laptop that receives status events from a CLI and streams them
over WSS to an iPhone SE on the desk. The phone runs a home-screen web page served by
the daemon and reached through tailscale serve (tailnet only, never funnel). The page
shows one big status card at a time, built for coding-agent states:
--state |
Card | Top-left |
|---|---|---|
allow |
allow. in red | 18m so far (live) |
your_turn |
your (white) / turn. (red) | 18m so far (live) |
all_set |
all / set. in green | took 1m |
working |
working. in grey | 5m so far (live) |
| (none) | the title, coloured by --level (info white, warn amber, error red) |
The top line shows the sender icon, the app name, the timer and a short session tag
(# plus the last 4 characters of the session id). Below the headline come the project
(and host), the session label, the task line (the title, unless it only repeats the
headline) and the detail line (detail, else the subtitle or body). Every line is cut
with an ellipsis, so the card never scrolls. The footer holds the time and, after a gap
in the replay, a small "N events missed" note for two minutes. A swipe up, or the ☰
button, opens a drawer with the last 100 events, each with its icon and session tag;
tapping one shows it until the next event arrives. When an event has a url, tapping
the card opens it in Safari.
nearpush send ──HTTP+token──▶ nearpushd 127.0.0.1:8787 ◀── tailscale serve (https) ◀── iPhone Safari
│ events.jsonl GET /, GET /ws
crates/nearpushd: the daemon (axum + tokio). The web page, manifest and icons live incrates/nearpushd/assets/and are compiled into the binary.crates/nearpush: the CLI (clap + reqwest/rustls).macos/: the Dock app with a settings window and an optional menu bar icon (SwiftPM;NearpushCorelibrary +Nearpushexecutable).scripts/build-app.shbundles it with nearpushd intoNearpush.app.tools/gen_icons.py: regenerates the PNG icons (standard-library Python).docs/kiro.md: how Kiro sessions drive the display, with diagrams.
Build on a build host (arm64 macOS, with Rust installed), never on the laptop. Replace
<build-host> with its SSH name. The default target works on both machines.
rsync -a --delete --exclude target/ --exclude .git/ --exclude .agents/ ./ <build-host>:~/repo/nearpush/
ssh <build-host> 'cd ~/repo/nearpush && export PATH=$HOME/.cargo/bin:$PATH && cargo build --release && cargo test'
mkdir -p ~/.local/bin
scp <build-host>:~/repo/nearpush/target/release/{nearpushd,nearpush} ~/.local/bin/.github/workflows/release.yml builds the macOS app on a GitHub-hosted arm64 runner.
- The version is
[workspace.package] versioninCargo.toml. Pushing a tag runs the build only when the tag is exactlyvplus that version. Otherwise the run fails. - The tests (
cargo test --workspaceandswift test) run before the app is built. - A tag with a hyphen, such as
v0.4.0-rc1, publishes as a prerelease. - The release attaches
Nearpush-X.Y.Z-macos-arm64.zip. An existing asset is never overwritten: the run fails with a message instead. workflow_dispatchis a dry run: it runs the tests and uploads the zip as a workflow artifact. It creates no release.
- The app runs on Apple silicon (arm64) Macs only. Intel Macs cannot run it.
- The app is ad-hoc signed and not notarized. Gatekeeper blocks the first launch of a
browser-downloaded copy. To open it on macOS 15:
- Move
Nearpush.appto/Applicationsand try to open it once. macOS blocks it. - Open System Settings → Privacy & Security, find the message about Nearpush, click Open Anyway, and confirm with your password.
- Move
- Or remove the quarantine flag in Terminal:
xattr -dr com.apple.quarantine /Applications/Nearpush.app
| Path | What |
|---|---|
~/.config/nearpush/config.toml |
optional config (below) |
~/.config/nearpush/token |
push token, created on first start with mode 0600 |
~/.local/state/nearpush/events.jsonl |
one JSON event per line, reloaded on start (0600) |
~/.local/state/nearpush/sessions.json |
per-session prompt history (capped) and summarized labels (0600) |
~/.local/state/nearpush/hooks/*.json |
nearpush hook per-session state (0600) |
~/.local/state/nearpush/control.json |
runtime pause and filter set through POST /control (0600) |
~/Library/Logs/nearpush/nearpushd.log |
daemon log when started by the menu bar app (--log-file, 0600) |
~/.config/nearpush/icons/<sender>.{svg,png} |
optional icon overrides |
The state directory is forced to 0700 and its files to 0600 on every start.
NEARPUSH_CONFIG_DIR and NEARPUSH_STATE_DIR (or --config-dir / --state-dir)
override the two directories.
# ~/.config/nearpush/config.toml (every key is optional)
listen = "127.0.0.1:8787" # must be loopback; nearpushd refuses anything else
allowed_logins = ["you@example.com"] # Tailscale-User-Login values allowed to view
buffer_size = 500 # events kept in memory for replay (1..=10000)
allow_loopback_without_login = false # local testing: let header-less loopback requests view
public_url = "https://laptop.example.ts.net/" # shown in /status; unset: from tailscale status
[filter] # which agent states the page shows
states = ["all_set", "your_turn", "allow"] # default; add "working" to see turn starts
[hook] # read by `nearpush hook` only
include_subagents = false # true: workflow-step / sub-agent sessions push too
[summarizer] # LLM that labels each Kiro session with its purpose
enabled = true
command = ["kiro-cli", "chat", "--no-interactive", "--model", "claude-haiku-5.5", "--trust-tools="]
timeout_secs = 30
keychain_service = "nearpush-kiro-api-key" # KIRO_API_KEY for each run; "" = no lookup
[classifier] # decides your_turn vs all_set at each Stop (see below)
enabled = true # false: rules verdict only, no provider calls
provider = "kiro" # "kiro" | "openrouter" | "typesafe"; menu bar app can change it live
threshold = 0.5 # openrouter/typesafe: noul at or above this is your_turn (0..=1)
timeout_secs = 30 # provider's own limit (1..=30); the 15 s card hold is the binding limitDisplay filter. An event is shown when it is not muted (see pause below) and either
has no state (generic nearpush send events always show) or has a state listed in
[filter] states. Hidden events are stored in events.jsonl and listed in
GET /status with "shown": false, but they are never broadcast or replayed, so
neither the main card nor the history drawer shows them. POST /control {"states": [...]}
changes the list at runtime and applies to open sockets at once; the override is kept in
~/.local/state/nearpush/control.json (0600) and from then on wins over [filter] in
config.toml (delete control.json and restart to go back to the config value). The
[filter] and [hook] sections need the nearpushd from this version: the older
daemon rejects unknown config keys.
Swipe to dismiss. On the phone, swipe the main card left or right (past about a
third of its width, or a quick flick) to dismiss it; a "Dismissed · Undo" toast stays
for 5 s. A your_turn card leaves the pending list, so the card moves to the next
pending session or to all set; any other card is hidden and the page shows "nothing
pending." until the next event. Every viewer follows. When the undo window ends, the
daemon writes a "dismissed": true record to history (no notification). The page calls
POST /v1/dismiss and POST /v1/undismiss with {"session": "<id>"} or
{"event_id": N}. They are the only routes a tailnet viewer can write to, and they
accept a request only when its Tailscale-User-Login is in allowed_logins, its
Origin matches the host, and its body is JSON. A direct loopback request with the push
token also works. Each caller gets 10 calls per minute. These routes can remove or
restore a pending entry or hide a card. They cannot create, edit or push an event, and
they cannot change pause, the filter or the classifier. The page test runs with
scripts/test-page.sh (JavaScriptCore, macOS).
The broadcast channel per viewer is never larger than buffer_size, so a viewer that
falls behind can normally resync from the buffer. When it cannot, or when a reconnecting
page asks for events older than the buffer, the daemon says how many were skipped and the
page shows the "N events missed" note.
When allowed_logins is empty, nearpushd asks tailscale status --json for this node's
own login (it retries every 30 s until tailscaled answers) and allows only that login.
Set the list explicitly to share the display with another tailnet user.
The event log is compacted on start when it holds more than twice buffer_size lines
(or a damaged line), and again at runtime whenever it passes twice buffer_size. The
newest events are kept, so ids never go backwards.
nearpush hook sends the user's prompt with each UserPromptSubmit event (field
prompt). The daemon never writes it to events.jsonl; it keeps the first prompt and
the last four per session (500 characters each, 100 sessions) in sessions.json, and
runs the summarizer on a session's first prompt, every fifth prompt, and when a prompt
shares almost nothing with the earlier ones. Runs are debounced, one at a time, killed
after timeout_secs. The command gets a fixed instruction plus the prompts on stdin and
must print the label on stdout; it is cut to 30 CJK or 50 other characters. It runs in
~/.local/state/nearpush/summarizer/ with NEARPUSH_DISABLE=1 set, which makes
nearpush hook exit at once, so a summarizer kiro-cli run with the nearpush hooks
installed does not report itself. A new label is broadcast as a session_update frame;
the page updates that session's cards in place, and later events of the session carry
the label. If the summarizer is missing, fails or times out, the hook's provisional
label (the first prompt, truncated to 40 characters) stays and a warning is logged.
After a failed run the session gets no new run for 1 minute, doubling per consecutive
failure up to 1 hour, so a logged-out kiro-cli does not start a process on every prompt.
Cost and data flow. The summarizer is on by default. Each run is one
kiro-cli chat call on claude-haiku-5.5 (0.06x credits) billed to the Kiro account
kiro-cli is logged in with, and it sends that session's stored prompts (first + last
four, 500 characters each) to Kiro's model service. Expect about one run per session
plus one per five prompts or topic change. Set [summarizer] enabled = false to stop
this: the daemon then keeps no prompts at all (nothing is written to sessions.json)
and the cards show the hook's provisional label.
The LaunchAgent's environment has no KIRO_API_KEY, so before each run nearpushd reads
it from the login keychain with
/usr/bin/security find-generic-password -s nearpush-kiro-api-key -w and sets it only
in that run's environment. Only exit status 0 counts (a missing item exits 44); the
trailing newline is trimmed, the key is never logged, written to state or put on argv,
and it is replaced by [redacted] in the stderr excerpt of a failed run. Without a key
the run is skipped (with the same backoff) and the provisional label stays. Store the
key once:
security add-generic-password -a "$USER" -s nearpush-kiro-api-key -T /usr/bin/security -w[summarizer] keychain_service = "" turns the lookup off (the command then has to find
its own login, e.g. kiro-cli login); security_program (default /usr/bin/security)
exists for tests.
At each Stop with a reply, the card is held up to 15 s while a classifier decides between
your turn. and all set.. The hook sends the last 8 non-empty reply lines, redacted,
with the rules verdict; the daemon redacts them again before any provider call. The
provider answers, and the daemon then commits the card. If the provider fails, is missing
its key, or misses the 15 s hold, the card goes out with the rules verdict. A
working. card is never classified. A waiting workflow run never forces your turn. and
never skips the classifier.
| Provider | Model | Where the reply tail goes | Keychain service |
|---|---|---|---|
kiro (default) |
Haiku 5.5 through kiro-cli chat --no-interactive |
Kiro's model service, billed to the Kiro account | nearpush-kiro-api-key |
openrouter |
Jev 1.13 (typesafe/jev-1.13) at https://openrouter.ai/api/v1/systemone |
OpenRouter, which forwards the request in plaintext to the Jev provider | nearpush-openrouter-api-key |
typesafe |
Jev 1.13.0 (jev-1.13.0) at https://api.typesafe.ai/v1/systemone |
TypeSafe, directly | nearpush-typesafe-api-key |
Jev answers with a probability (noul) for the question "is the assistant's final message
asking the user for a decision or input?". Values at or above threshold mean your turn..
Jev is English-first, and its answer is probabilistic, so expect some misses on Chinese
replies. Each Stop with the kiro provider runs one kiro-cli call.
Store the keys you use (kiro uses the same item as the summarizer):
security add-generic-password -a "$USER" -s nearpush-openrouter-api-key -T /usr/bin/security -w
security add-generic-password -a "$USER" -s nearpush-typesafe-api-key -T /usr/bin/security -wThe menu bar app's Classifier submenu switches the provider at runtime
(POST /control {"classifier": "typesafe"}, kept in control.json), and Diagnostics shows
whether the selected key is present. [classifier] enabled = false turns the provider
calls off; then the classifier submenu is disabled.
The hook sends two fields the older daemon does not know (classify on Stop and
turn_start on each prompt), and the daemon rejects unknown fields. Roll back the CLI
together with the daemon: restore ~/.local/bin/nearpush from the previous build
(nearpush.prev in the deploy folder) in the same step as the app or daemon. A new CLI
with an older daemon loses each prompt and Stop event with a 400.
Nearpush.app is a Dock app (macOS 14+). It opens a settings window at launch and when
you click its Dock icon. The window has Status, Display (pause and Show on iPhone per state),
Classifier, Phone (URL, copy, QR code), Relay, Diagnostics, and General (Launch at Login, Show menu
bar icon, Quit). Closing the window keeps the app running. The menu bar icon is optional and is
controlled by the "Show menu bar icon" setting. The app bundles nearpushd at
Contents/MacOS/nearpushd and keeps it running. Build it on the build host and
install it on the laptop:
rsync -a --delete --exclude target/ --exclude .build/ --exclude .agents/ --exclude .git/ --exclude build/ ./ <build-host>:~/repo/nearpush/
ssh <build-host> 'cd ~/repo/nearpush && export PATH=$HOME/.cargo/bin:$PATH && NEARPUSH_BUILD_HOST="<build-host-glob>" scripts/build-app.sh'
scp '<build-host>:~/repo/nearpush/build/Nearpush-*-macos-arm64.zip' "$TMPDIR"/ && ditto -x -k "$TMPDIR"/Nearpush-*-macos-arm64.zip ~/Applications/
scp <build-host>:~/repo/nearpush/build/nearpush ~/.local/bin/nearpush # the matching CLI
open ~/Applications/Nearpush.appscripts/build-app.sh runs cargo build --release, swift build -c release, assembles
build/Nearpush.app, signs it ad hoc (SIGN_ID overrides the identity) and zips it.
It refuses to run unless the host name matches NEARPUSH_BUILD_HOST (a shell glob, for
example build-*). NEARPUSH_ALLOW_LOCAL_BUILD=1 skips that check.
Daemon lifecycle. The app registers the bundled daemon as a launchd agent with
SMAppService.agent (Contents/Library/LaunchAgents/dev.nearpush.agent.plist, label
dev.nearpush.agent, KeepAlive {SuccessfulExit = false}). This works with the ad-hoc
signature on macOS 26 and is the mode in use on the laptop. The daemon keeps running when
the menu is quit, and launchd restarts it after a crash. If registration fails, the app
unregisters, remembers daemonMode = child with the reason (shown under Diagnostics) and
runs nearpushd as a child process, restarted with a 1 s to 30 s backoff; Quit then stops
it. defaults write dev.nearpush.app daemonMode agent|child forces a mode.
Before starting anything the app probes 127.0.0.1:8787. If another nearpushd answers (an
older one without /status, or one at a different path), the menu says so and the app
starts nothing. nearpushd itself exits 0 when the port is taken, so launchd never
crash-loops against another daemon.
Menu. Status line (running or paused, iPhone count, latest event); iPhone URL with
a QR code window and Copy URL; the last 5 events (click to copy; filtered ones are marked
"(hidden)"); Pause pushes; Show on iPhone (one checkbox per state, applied live via
/control); Summarizer (edits [summarizer] enabled in config.toml and restarts the
daemon); Classifier (Kiro · Haiku 5.5, OpenRouter · Jev 1.13 or TypeSafe · Jev 1.13.0,
applied live through /control); Launch at Login (SMAppService.mainApp); Diagnostics (daemon reachable,
token file, tailscale serve maps to 8787, Keychain item present, by exit status only,
the daemon mode and the login-item status; Set up tailscale serve appears only when
there is no serve config at all); Restart daemon; Open logs; Quit.
Logs: ~/Library/Logs/nearpush/nearpushd.log (0600, rotated to .1 past 10 MiB at
start). The app reads the token from ~/.config/nearpush/token.
~/.local/bin/nearpushd is not used by the app. It is left in place as the rollback
binary for the hand-written LaunchAgent below.
- In the menu, turn off Launch at Login, then Quit Nearpush.
- Stop the bundled agent:
launchctl bootout gui/$(id -u)/dev.nearpush.agent. To remove its registration for good, delete~/Applications/Nearpush.app(or toggle it off in System Settings › General › Login Items & Extensions). - Check that nothing holds the port:
pgrep -fl nearpushdprints nothing. mv ~/Library/LaunchAgents/dev.nearpush.daemon.plist.bak-menubar ~/Library/LaunchAgents/dev.nearpush.daemon.plist && launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.nearpush.daemon.plistmv ~/.local/bin/nearpush.bak-menubar ~/.local/bin/nearpush, in the same step as the daemon restore in step 4.- Remove
[filter],[hook],[classifier]andpublic_urlfrom~/.config/nearpush/config.tomlif you added them; the old daemon refuses to start with unknown keys. Theclassifierkey thatcontrol.jsonmay hold is ignored by the old daemon, so that file needs no change. ~/.local/state/nearpush/control.jsoncan stay (the old daemon ignores it). Events stored while paused carry"muted": true; the old daemon still loads them (itsEventaccepts unknown fields) but will replay them like any other event.
Use this or the menu bar app, not both: only one nearpushd can own 8787.
deploy/install-launchagent.sh renders deploy/dev.nearpush.daemon.plist.in into
~/Library/LaunchAgents/dev.nearpush.daemon.plist (replacing @HOME@), creates the log
directory and bootstraps it (booting out a previous copy first). The result is:
~/Library/LaunchAgents/dev.nearpush.daemon.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>dev.nearpush.daemon</string>
<key>ProgramArguments</key>
<array><string>/Users/<user>/.local/bin/nearpushd</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/Users/<user>/Library/Logs/nearpush/nearpushd.out.log</string>
<key>StandardErrorPath</key><string>/Users/<user>/Library/Logs/nearpush/nearpushd.err.log</string>
</dict>
</plist>mkdir -p ~/Library/Logs/nearpush
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.nearpush.daemon.plist
launchctl kickstart -k gui/$(id -u)/dev.nearpush.daemon # restart after replacing the binaryKeep the binary on the internal disk: launchd agents cannot read files on external volumes.
Check what is already served first, and do not replace an existing / mapping:
tailscale serve status
tailscale serve --bg http://127.0.0.1:8787 # https://<laptop>.<tailnet>.ts.net/
# if https:443 is taken:
tailscale serve --bg --https=8443 http://127.0.0.1:8787 # https://<laptop>.<tailnet>.ts.net:8443/Never enable funnel. The page and socket rely on the Tailscale-User-Login header that
tailscale serve adds for tailnet users.
nearpush send --title "build finished"
nearpush send --title "F38 多工位会话拼单" --state allow --subtitle "part of the source"
nearpush send --title "frontend 重構" --state your_turn --project "frontend / 7008" --started-at 18m
nearpush send --title "Claude母公司招股书精读" --state all_set --duration 1m
nearpush send --title "deploy failed" --level error --url https://github.com/... --tag ci
make 2>&1 | tail -20 | nearpush send --title "make output" --body -| Flag | Meaning |
|---|---|
--title |
required; the headline for generic events, the task line for --state events (≤ 200 chars) |
--body |
text (≤ 4000 chars); - reads stdin (≤ 16 KiB) |
--level |
info (default), warn, error |
--state |
allow, your_turn, all_set, working (your-turn/all-set also accepted) |
--subtitle |
short line under the headline (≤ 200 chars) |
--project |
footer text (≤ 100 chars) |
--started-at |
now, unix seconds or milliseconds, an RFC 3339 time such as 2026-11-04T18:49:33Z or 2026-11-05T02:49:33+08:00 (an offset is required), or an age such as 18m / 1h5m; drives the live "Xm so far" |
--duration |
65, 90s, 1m, 1h5m; shown as "took Xm" |
--url |
http(s) link opened when the card is tapped |
--tag |
repeatable, ≤ 10 tags of ≤ 32 chars |
--sender |
icon key, [a-z0-9-], ≤ 32 chars; default cli |
--app |
app name on the top line (≤ 32 chars); default derived from the sender |
--session |
stable session id (≤ 128 chars); its last 4 characters become the tag |
--session-label |
what the session is for (≤ 100 chars) |
--host |
machine name (≤ 64 chars) |
--detail |
extra context line (≤ 300 chars) |
--addr / NEARPUSH_ADDR |
daemon URL, default http://127.0.0.1:8787 |
--token-file / NEARPUSH_TOKEN_FILE |
default ~/.config/nearpush/token |
It prints sent event #<id> and exits 0, or prints the reason and exits 1.
nearpush hook reads a Kiro hook event on stdin
(hook_event_name, cwd, session_id, plus prompt or tool_name/tool_input) and
pushes a card with sender kiro, app Kiro, the project (cwd basename and git branch,
read from .git/HEAD), the session id, the label, the host and a detail line:
| Kiro event | Card |
|---|---|
UserPromptSubmit |
working. (hidden by the default filter); starts the turn timer and sends the prompt to the summarizer |
PreToolUse |
working. with tool: summary, e.g. execute_bash: cargo test (the same tool is re-sent at most every 5 s); optional, see below |
Stop |
your turn. with the question line when the reply waits on you, else all set. with took Xm |
SessionStart, PostToolUse |
nothing |
SessionEnd |
removes the session's state file |
Stop: your turn. vs all set. Kiro's Stop input carries only hook_event_name,
cwd and session_id, no reply text (hook docs,
Kiro#6258). The hook reads the reply
from the transcript Kiro CLI writes before the hook runs,
~/.kiro/sessions/<workspace-hash>/<session_id>/messages.jsonl: the last assistant
record with operationType: "Say" after the latest turn_start, from the file's last
512 KiB. A response, assistant_response or last_assistant_message string on stdin
wins if Kiro adds one. A fast local heuristic, run on the last 8 lines outside code
blocks, picks your turn. when the last line ends with ?/?; when two or more
numbered or lettered options follow an intro line that ends with ?, or with : and a
choice word (option, choose, pick, which, 選, 方案, 哪); or when one of the last 3 lines
contains a phrase such as 請選, 要不要, 你決定, 跟我說, 告訴我, 是否要, 可以嗎, 好嗎,
選哪, 哪一個, "which option", "should I", "let me know", "your call", "do you want",
"would you like", "shall I", "want me to", "please confirm". The question line
(redacted, at most 80 characters) becomes the detail. The card is sent at once; no model
is asked. Without text the card is all set..
Workflow runs. At Stop the hook counts this session's waiting workflow runs from
~/.kiro/sessions/<hash>/workflows/wf_*/workflow-state.json (parentSessionId = the
session, status = paused, state changed in the last 24 h; read-only, 40 ms budget,
skipped on any error) and sends the count as a separate workflows_waiting field. The
daemon shows it only as an FYI footer N wf waiting on a card whose final verdict is
all set.. It never changes the verdict, a your turn. card shows no count, and running
counts are not shown.
Sub-agent sessions. Workflow-step sessions fire global hooks too. The hook skips a
session whose session.json has a rootConversationId other than its own id, a
_meta.kiro.workflow object or a _meta.kiro.serverSession.ownerSessionId, so only the
session you talk to notifies. [hook] include_subagents = true turns the skip off.
It never shows allow.: the hook input carries no approval information (see below). The
tool summary uses only command, path/file_path, url, query or pattern, keeps
the first line, and hides env assignments (TOKEN=…), values after secret-looking flags,
Bearer credentials, URL passwords and token-shaped words.
The hook always exits 0, prints nothing on stdout (Kiro adds a successful
SessionStart/UserPromptSubmit hook's stdout to the agent's context), gives the daemon
under a second, and stays quiet when the daemon is down. With NEARPUSH_DISABLE=1 it
exits before reading anything.
Add the entries in examples/kiro-agent-hooks.json to
the hooks array of an agent profile in ~/.kiro/agents/<agent>.json:
"hooks": [
{ "name": "nearpush-prompt", "trigger": "UserPromptSubmit", "action": { "type": "command", "command": "~/.local/bin/nearpush hook" }, "timeout_ms": 3000 },
{ "name": "nearpush-stop", "trigger": "Stop", "action": { "type": "command", "command": "~/.local/bin/nearpush hook" }, "timeout_ms": 3000 },
{ "name": "nearpush-end", "trigger": "SessionEnd", "action": { "type": "command", "command": "~/.local/bin/nearpush hook" }, "timeout_ms": 3000 }
]Optional: PreToolUse only produces working. cards, so add it only when working is
in [filter] states:
{ "name": "nearpush-tool", "trigger": "PreToolUse", "action": { "type": "command", "command": "~/.local/bin/nearpush hook" }, "timeout_ms": 3000 }With the default config, the prompts of every hooked session also go to the session summarizer, an LLM call billed to your Kiro account (see Session labels for the data flow and how to turn it off).
Replace ~ with your home directory if your shell setup does not expand it. To cover
every agent instead, put the same entries in a standalone hook file,
~/.kiro/hooks/nearpush.json, as { "version": "v1", "hooks": [ ... ] } (CLI V3 loads
global hook files for every workspace). Use one of the two placements, not both, or each
event is pushed twice. docs/kiro.md walks through one turn with
diagrams.
Kiro's PreToolUse fires for every tool call, before the permission check is known to
the hook, and its input has no field saying whether the call will prompt. Whether it
prompts depends on rules from several scopes (Kiro's built-in asks, managed policy,
~/.kiro/settings/permissions.yaml, the workspace, the agent's permissions, approvals
given earlier in the session, and the approve-for-me classifier), so the hook cannot
reproduce the decision. Kiro also has no hook for "waiting for approval" or "waiting for
input": Stop (the turn finished) is the only moment the agent waits for the user. The
hook maps it to your turn. when the final reply asks something and to all set.
otherwise (see above). Use nearpush send --state allow by hand or from another tool.
POST /push: JSON body withtitleand the optional fields above (started_atis unix ms,durationis sent asduration_ms). Requires a direct loopback peer, the headerAuthorization: Bearer <token>, and no proxy headers: a request carrying anyTailscale-*,X-Forwarded-For,X-Forwarded-HostorForwardedheader is refused with 403, so nothing coming throughtailscale servecan push. Bodies over 16 KiB get 413; unknown fields or values that break a limit get 400. Returns{"id":N,"ts":ms}.GET /status: same checks as/push(loopback peer, bearer token, no proxy headers; throughtailscale serveit is 403). Returnsversion,ui_version,pid,exe,started_at_ms,uptime_secs,viewers(open WebSockets),last_event_id,paused,filter(states,source:configorcontrol),summarizer(enabled,healthy,last_ok_ms,last_error_ms),public_urlandrecent(the last 5 events, hidden ones included, each withshown).POST /control: same checks. Body{"paused"?: bool, "states"?: [state, …]}(at least one key, unknown keys or states → 400); returns{paused, filter}. Changes apply to open sockets at once and are saved incontrol.json. Pause means:/pushstill stores the event, marked"muted": trueinevents.jsonl, but it is never broadcast and never replayed, even after unpausing or a restart. There is no muted marker frame; the page simply gets nothing.GET /ws?since=<id>: WebSocket. Frames are JSON text:hello(version,last_id,oldest_id,missed,server_time,labels), then oneeventper buffered event with id >since, thensynced, then liveevents.missedcounts events aftersincethat are no longer buffered;labelslists known{session, label}pairs. A protocol ping and a{"type":"ping"}frame go out every 20 s. A subscriber that falls behind the live channel is resynced from the buffer, not dropped; if events fell out of the buffer meanwhile it first gets{"type":"missed", "missed":N}.{"type":"session_update","session":…,"label":…}arrives when the summarizer labels a session. A browserOriginmust match theHostheader.GET /icons/<sender>: the sender's icon.~/.config/nearpush/icons/<sender>.svg, then.png, then a built-in icon (kiro: the official logo from https://kiro.dev/icon.svg), then a generated tile with the sender's first letter. Only[a-z0-9-]{1,32}names resolve; anything else is 404.GET /,/manifest.webmanifest,/icon-*.png,/icons/*: need an allowlistedTailscale-User-Login(or the local-testing flag).
- Install Tailscale on the iPhone and sign in to the same tailnet as the laptop.
- In Safari open
https://<laptop>.<tailnet>.ts.net/(append:8443if you served on that port). You should see a black screen with "waiting." and a green dot. - Tap the Share button, then Add to Home Screen, then Add.
- Open nearpush from the home screen. It runs full screen, in portrait or landscape.
- For a desk display: Settings → Display & Brightness → Auto-Lock → Never, and keep the phone on its charger. The page also asks for a Screen Wake Lock (iOS 16.4+; the drawer shows whether it holds one, and a tap re-requests it). To pin the phone to this page, use Guided Access (Settings → Accessibility → Guided Access, then triple-click the side button inside the app), which also lets you disable the Auto-Lock timer for the session.
The dot in the top right is green when live, amber while connecting and red while offline (the drawer header shows the retry countdown).
iOS suspends a web page as soon as it leaves the screen, and its WebSocket goes with it.
There is no Web Push here, so nothing arrives while the page is in the background or the
phone is locked; it is a display, not a notifier. When the page becomes visible again
(visibilitychange / pageshow) it reconnects at once with since=<last seen id> from
localStorage, so every event pushed in the meantime is replayed, as long as it is still
in the daemon's buffer (buffer_size, default 500). While visible it reconnects with
exponential backoff (1 s doubling to 30 s) and treats 50 s of silence as a dead socket.
The hello frame carries the UI version (crate version plus a hash of the embedded
assets). When it differs from the version the page was loaded with, the page reloads
itself, so installing a new nearpushd updates the phone with no action on it.