@pushary/agent-hooks 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/cursor-plugin/.cursor-plugin/plugin.json +25 -0
- package/data/cursor-plugin/CHANGELOG.md +10 -0
- package/data/cursor-plugin/LICENSE +21 -0
- package/data/cursor-plugin/README.md +111 -0
- package/data/cursor-plugin/SECURITY.md +33 -0
- package/data/cursor-plugin/assets/logo.png +0 -0
- package/data/cursor-plugin/commands/notify-when-done.md +14 -0
- package/data/cursor-plugin/commands/pushary-test.md +13 -0
- package/data/cursor-plugin/hooks/hooks.json +13 -0
- package/data/cursor-plugin/mcp.json +11 -0
- package/data/cursor-plugin/rules/pushary.mdc +40 -0
- package/data/cursor-plugin/scripts/pushary-gate.mjs +349 -0
- package/data/cursor-plugin/skills/pushary/SKILL.md +297 -0
- package/dist/bin/pushary-clean.js +7 -0
- package/dist/bin/pushary-setup.js +37 -28
- package/package.json +2 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pushary",
|
|
3
|
+
"displayName": "Pushary — Control Panel for AI Agents",
|
|
4
|
+
"description": "Push notifications, human-in-the-loop questions, and permission gating for your AI coding agent. Get a push when a task finishes, answer the agent from your phone, and approve risky commands before they run.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"author": { "name": "Pushary", "email": "business@pushary.com" },
|
|
7
|
+
"homepage": "https://pushary.com",
|
|
8
|
+
"repository": "https://github.com/Pushary/cursor-plugin",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"notifications",
|
|
12
|
+
"push",
|
|
13
|
+
"human-in-the-loop",
|
|
14
|
+
"permissions",
|
|
15
|
+
"approvals",
|
|
16
|
+
"mcp",
|
|
17
|
+
"agent",
|
|
18
|
+
"control-panel",
|
|
19
|
+
"ask",
|
|
20
|
+
"alerts"
|
|
21
|
+
],
|
|
22
|
+
"category": "developer-tools",
|
|
23
|
+
"tags": ["notifications", "approvals", "human-in-the-loop", "mcp", "workflow"],
|
|
24
|
+
"logo": "assets/logo.png"
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- MCP server (`send_notification`, `ask_user`, `wait_for_answer`, `cancel_question`) wired via `mcp.json`, key supplied at runtime through `PUSHARY_API_KEY`.
|
|
8
|
+
- Always-on rule and full tool-reference skill so the agent uses Pushary proactively.
|
|
9
|
+
- `beforeShellExecution` gate (`scripts/pushary-gate.mjs`, zero-dependency) that evaluates risky commands against your Pushary dashboard policy — auto-approve, the four approval modes, timeout actions, live mode override, and kill switch, scoped to the Cursor conversation. Falls back to Cursor's own prompt when Pushary is unreachable, and is fail-closed: a broken gate blocks rather than allows.
|
|
10
|
+
- Commands: `/pushary-test`, `/notify-when-done`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pushary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="Pushary" width="72" height="72" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Pushary for Cursor</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">A control panel for your AI coding agent. Get a push when work finishes, answer the agent from your phone, and approve risky commands before they run.</p>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it is
|
|
12
|
+
|
|
13
|
+
Pushary connects Cursor to your phone. When the agent finishes a task, needs a decision, or is about to run something risky, it reaches you with a push notification. You answer from the lock screen and the agent keeps going. It works even when you have stepped away from your computer.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
There are three things.
|
|
18
|
+
|
|
19
|
+
1. Notify. The agent sends a push when a long task finishes, or when a build, test, or deploy fails. The push can include what changed, the error, and suggested next steps.
|
|
20
|
+
|
|
21
|
+
2. Ask. The agent asks you questions through push: yes or no, multiple choice, or free text. It waits for your answer. When Pushary is connected, the agent sends its questions to your phone instead of waiting in the editor.
|
|
22
|
+
|
|
23
|
+
3. Gate. Risky shell commands (like rm, force push, history rewrites, database drops, deploys, and systemctl) are checked before they run. What happens is set by your Pushary dashboard policy: auto approve trusted commands, push to your phone for approval, or just notify. If you do not answer in time, it falls back to Cursor's own prompt, so nothing dangerous runs silently. If the check cannot run at all, the command is blocked instead of allowed.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
You need two things: the plugin, and an API key.
|
|
28
|
+
|
|
29
|
+
### Option 1: Cursor Marketplace (recommended)
|
|
30
|
+
|
|
31
|
+
Open the Marketplace panel in Cursor, search for Pushary, and click install. Then set your API key (see below).
|
|
32
|
+
|
|
33
|
+
### Option 2: CLI
|
|
34
|
+
|
|
35
|
+
This also sets up Claude Code, Codex, and Hermes if you use them.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx @pushary/agent-hooks@latest setup
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Option 3: Manual MCP
|
|
42
|
+
|
|
43
|
+
Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"pushary": {
|
|
49
|
+
"type": "http",
|
|
50
|
+
"url": "https://pushary.com/api/mcp/mcp",
|
|
51
|
+
"headers": { "Authorization": "Bearer ${PUSHARY_API_KEY}" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
However you install it, Pushary talks to the same server, so the plugin and the CLI give you the same setup.
|
|
58
|
+
|
|
59
|
+
## Set your API key
|
|
60
|
+
|
|
61
|
+
The plugin reads your key from the `PUSHARY_API_KEY` environment variable. Get a key at https://pushary.com, then add it to your shell profile:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
echo 'export PUSHARY_API_KEY="pk_xxx.sk_xxx"' >> ~/.zshrc
|
|
65
|
+
source ~/.zshrc
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Install the Pushary app on your phone (or turn on web push) so the agent can reach you.
|
|
69
|
+
|
|
70
|
+
## What is in the plugin
|
|
71
|
+
|
|
72
|
+
| Part | File | What it does |
|
|
73
|
+
|------|------|--------------|
|
|
74
|
+
| MCP server | `mcp.json` | Connects Cursor to the Pushary tools: `send_notification`, `ask_user`, `wait_for_answer`, `cancel_question` |
|
|
75
|
+
| Rule | `rules/pushary.mdc` | Always on guidance so the agent uses Pushary on its own |
|
|
76
|
+
| Skill | `skills/pushary/SKILL.md` | Full tool reference: parameters, examples, return values |
|
|
77
|
+
| Hook | `hooks/hooks.json` and `scripts/pushary-gate.mjs` | Sends risky commands to your phone for approval |
|
|
78
|
+
| Commands | `commands/` | `/pushary-test` and `/notify-when-done` |
|
|
79
|
+
|
|
80
|
+
## How the gate decides
|
|
81
|
+
|
|
82
|
+
There are two layers.
|
|
83
|
+
|
|
84
|
+
1. The matcher in `hooks/hooks.json` is a list of patterns that decides which commands get checked at all. Edit it to add or remove patterns.
|
|
85
|
+
|
|
86
|
+
2. Your Pushary dashboard policy decides what happens to a checked command, per tool: auto approve, the approval mode (push and wait, push then prompt, notify only, or prompt only), the timeout action, a live mode override, and the kill switch. This is the same policy your other Pushary agents use, so the behavior stays the same across agents.
|
|
87
|
+
|
|
88
|
+
## Commands
|
|
89
|
+
|
|
90
|
+
- `/pushary-test` sends a test push so you can confirm delivery.
|
|
91
|
+
- `/notify-when-done` tells the agent to push a summary when the current task finishes.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
`skills/pushary/SKILL.md` mirrors the Pushary skill that ships with `@pushary/agent-hooks`. Keep the two the same.
|
|
96
|
+
|
|
97
|
+
Test the plugin locally before publishing:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
ln -s "$(pwd)" ~/.cursor/plugins/local/pushary
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then reload Cursor with Developer: Reload Window.
|
|
104
|
+
|
|
105
|
+
## Security
|
|
106
|
+
|
|
107
|
+
This repository has no secrets. Your key is read at runtime from `PUSHARY_API_KEY`. The gate script has no dependencies and only talks to pushary.com. Read `scripts/pushary-gate.mjs` to see exactly what it sends. See `SECURITY.md` for details.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Email **security@pushary.com** with details and reproduction steps. Please do not open a public
|
|
6
|
+
issue for security reports. We aim to acknowledge within 72 hours.
|
|
7
|
+
|
|
8
|
+
## What this plugin sends
|
|
9
|
+
|
|
10
|
+
The plugin connects Cursor to the Pushary MCP server at `https://pushary.com/api/mcp/mcp` using
|
|
11
|
+
the API key you provide via the `PUSHARY_API_KEY` environment variable. No key is committed to
|
|
12
|
+
this repository.
|
|
13
|
+
|
|
14
|
+
The permission gate (`scripts/pushary-gate.mjs`) runs only on shell commands matching the regex
|
|
15
|
+
in `hooks/hooks.json`. For a matched command it sends, over HTTPS to Pushary:
|
|
16
|
+
|
|
17
|
+
- the command text,
|
|
18
|
+
- the basename of the working directory (e.g. `my-repo`, not the full path),
|
|
19
|
+
- an agent label (`Cursor - <project>`),
|
|
20
|
+
- the Cursor conversation id, so the dashboard kill switch and per-session mode can target this session,
|
|
21
|
+
- a machine id — the first 8 hex characters of a SHA-256 of your hostname (never the hostname itself).
|
|
22
|
+
|
|
23
|
+
It also fetches your permission policy and mode from `pushary.com` (the policy is cached in the
|
|
24
|
+
system temp directory for 5 minutes). It contacts no host other than `pushary.com`, has no
|
|
25
|
+
third-party dependencies, and writes only that policy cache to disk. The full source is in this
|
|
26
|
+
repository — read it before installing.
|
|
27
|
+
|
|
28
|
+
## Fail-closed by design
|
|
29
|
+
|
|
30
|
+
The gate is configured `failClosed: true`. If the gate cannot produce a decision (crash,
|
|
31
|
+
timeout, invalid output), Cursor **blocks** the matched command rather than letting it run
|
|
32
|
+
unapproved. When Pushary is reachable but you don't answer in time, it falls back to Cursor's
|
|
33
|
+
own in-editor approval prompt.
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: notify-when-done
|
|
3
|
+
description: Send a Pushary push summarizing what changed when the current task finishes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
When you finish the current task, send a Pushary push notification so the user knows it's done — even if they have stepped away from the editor.
|
|
7
|
+
|
|
8
|
+
Call `send_notification` with:
|
|
9
|
+
- `title`: a short summary (under 60 chars) of what was completed
|
|
10
|
+
- `body`: one line on the outcome (under 200 chars)
|
|
11
|
+
- `agentName`: `"Cursor - {project}"`
|
|
12
|
+
- `context`: `{ type: "task_complete", summary, filesChanged, nextSteps }`
|
|
13
|
+
|
|
14
|
+
Send a single notification for the task. If the task fails instead, send `context.type: "error"` with `errorMessage` and the file where it failed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pushary-test
|
|
3
|
+
description: Send a test Pushary push notification to confirm notifications are working.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Send a test push notification with the Pushary `send_notification` tool so the user can confirm delivery on their phone.
|
|
7
|
+
|
|
8
|
+
Call `send_notification` with:
|
|
9
|
+
- `title`: "Pushary test"
|
|
10
|
+
- `body`: "If you can read this on your phone, Pushary is working."
|
|
11
|
+
- `agentName`: `"Cursor - {project}"` (use the current project folder name)
|
|
12
|
+
|
|
13
|
+
Then tell the user it was sent and to check their device. If the call fails, report the error and remind them to set the `PUSHARY_API_KEY` environment variable and sign in at https://pushary.com.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"beforeShellExecution": [
|
|
5
|
+
{
|
|
6
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
7
|
+
"matcher": "\\brm\\b|\\brmdir\\b|\\bunlink\\b|\\bmkfs|\\bdd\\b|\\bshutdown\\b|\\breboot\\b|\\bpkill\\b|\\bkillall\\b|\\bsystemctl\\b|--force\\b|force-push|reset --hard|\\brebase\\b|\\bdrop\\b|\\bDROP\\b|\\btruncate\\b|\\bTRUNCATE\\b|delete from|DELETE FROM|\\bdeploy\\b|\\bpublish\\b|\\brelease\\b|\\bmigrate\\b",
|
|
8
|
+
"timeout": 60,
|
|
9
|
+
"failClosed": true
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Pushary is connected. Use it to ask the user questions, notify them, and gate risky commands through push.
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pushary
|
|
7
|
+
|
|
8
|
+
Pushary is connected as an MCP server. It reaches the user on their phone, so use it on your own without being asked. Assume the user may be away from the editor.
|
|
9
|
+
|
|
10
|
+
## Ask the user through Pushary
|
|
11
|
+
|
|
12
|
+
When Pushary is connected, it is your channel for reaching the user. Any time you would stop and ask the user something, ask it through the `ask_user` tool and wait for the answer, instead of only asking in the chat. This covers all of your questions:
|
|
13
|
+
|
|
14
|
+
- Yes or no. Use type "confirm".
|
|
15
|
+
- Multiple choice. Use type "select" with 2 to 6 options.
|
|
16
|
+
- A name, a path, a value, or other free text. Use type "input".
|
|
17
|
+
- Approval of a plan or an approach before you start. Use type "select" with the options the user is choosing between, or "confirm" to approve a single plan.
|
|
18
|
+
|
|
19
|
+
`ask_user` blocks and returns `{ answered, value }`. Always set `context` to one line on what you are doing, and set `agentName` to "Cursor - {project}". If `answered` is false, you did not get an answer in time: pick the safe default or stop and tell the user. Never assume approval.
|
|
20
|
+
|
|
21
|
+
If the user answers in the chat before the push comes back, continue and call `cancel_question` with the correlationId to clean up.
|
|
22
|
+
|
|
23
|
+
## Notify the user
|
|
24
|
+
|
|
25
|
+
Use `send_notification` when something is worth the user's attention:
|
|
26
|
+
|
|
27
|
+
- A task of 3 or more steps finishes. Use `context.type` "task_complete" with a short summary and the files changed.
|
|
28
|
+
- A build, test, or deploy fails. Use `context.type` "error" with the error message.
|
|
29
|
+
- A long job finishes, such as a migration, refactor, or generation.
|
|
30
|
+
|
|
31
|
+
## Risky commands are gated for you
|
|
32
|
+
|
|
33
|
+
Risky shell commands (rm, force push, history rewrites, database drops, deploys, systemctl, and similar) are sent to the user for phone approval automatically by the plugin's hook, following the user's Pushary policy. You do not need to ask separately for those. For anything else destructive that the hook might not catch, ask first with `ask_user`.
|
|
34
|
+
|
|
35
|
+
## Keep it tidy
|
|
36
|
+
|
|
37
|
+
- Keep titles under 60 characters and bodies under 200. Put detail in the `context` object, not the title.
|
|
38
|
+
- Send at most 3 notifications per task unless the user asks for more.
|
|
39
|
+
|
|
40
|
+
For the full tool reference, with every parameter, examples, and return values, see the pushary skill.
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pushary gate — Cursor `beforeShellExecution` hook.
|
|
3
|
+
//
|
|
4
|
+
// Routes risky shell commands through your Pushary permission policy before they
|
|
5
|
+
// run. Which commands reach this gate is the `matcher` in ../hooks/hooks.json; what
|
|
6
|
+
// HAPPENS to a matched command is decided by your dashboard policy (the same policy
|
|
7
|
+
// the @pushary/agent-hooks CLI uses for Claude Code), so behavior is consistent
|
|
8
|
+
// across agents.
|
|
9
|
+
//
|
|
10
|
+
// It honors, per tool ("Bash"): auto-approve, the four approval modes
|
|
11
|
+
// (push_only / push_first / notify_only / terminal_only), the timeout action
|
|
12
|
+
// (approve / deny / escalate), a live mode override, and the kill switch — all
|
|
13
|
+
// scoped to the Cursor conversation. Policy is cached in the temp dir for 5 minutes
|
|
14
|
+
// with a stale-fallback, and requests retry.
|
|
15
|
+
//
|
|
16
|
+
// Self-contained: no dependencies, uses the global fetch (Node 18+).
|
|
17
|
+
//
|
|
18
|
+
// Contract (https://cursor.com/docs/hooks):
|
|
19
|
+
// stdin : { "command": string, "cwd": string, "conversation_id": string, ... }
|
|
20
|
+
// stdout : { "permission": "allow" | "deny" | "ask", "user_message"?, "agent_message"? }
|
|
21
|
+
//
|
|
22
|
+
// Failure model: every handled path writes a decision and exits 0. Network/parse
|
|
23
|
+
// errors and no-policy fall back to "ask" (Cursor's own prompt) — it never silently
|
|
24
|
+
// allows a risky command. A 55s hard guard guarantees a decision before the hook's
|
|
25
|
+
// `failClosed` deadline; only a catastrophic crash (e.g. Node missing) leaves no
|
|
26
|
+
// output, in which case `failClosed: true` blocks the command rather than allowing
|
|
27
|
+
// it unapproved.
|
|
28
|
+
|
|
29
|
+
import { createHash } from 'node:crypto'
|
|
30
|
+
import { hostname, tmpdir } from 'node:os'
|
|
31
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
32
|
+
import { basename, dirname, join } from 'node:path'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
|
|
35
|
+
const BASE_URL = 'https://pushary.com'
|
|
36
|
+
const MCP_URL = `${BASE_URL}/api/mcp/mcp`
|
|
37
|
+
const POLICY_CACHE_TTL_MS = 5 * 60 * 1000
|
|
38
|
+
const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
|
|
39
|
+
const WAIT_CHUNK_MS = 20_000 // per wait_for_answer long-poll
|
|
40
|
+
const POLL_GAP_MS = 1_500 // pause between polls after a transient error
|
|
41
|
+
const NET_TIMEOUT_MS = 27_000 // abort a single MCP request
|
|
42
|
+
const POLICY_TIMEOUT_MS = 10_000
|
|
43
|
+
const MODE_TIMEOUT_MS = 3_000
|
|
44
|
+
const HARD_GUARD_MS = 55_000 // force a graceful "ask" before failClosed (60s) fires
|
|
45
|
+
|
|
46
|
+
// ── Cursor decisions ──────────────────────────────────────────────────────────
|
|
47
|
+
const ALLOW = { permission: 'allow' }
|
|
48
|
+
const ask = (agentMessage) => (agentMessage ? { permission: 'ask', agent_message: agentMessage } : { permission: 'ask' })
|
|
49
|
+
const deny = (agentMessage) => ({ permission: 'deny', user_message: 'Command denied via Pushary.', agent_message: agentMessage })
|
|
50
|
+
|
|
51
|
+
let done = false
|
|
52
|
+
const respond = (decision) => {
|
|
53
|
+
if (done) return
|
|
54
|
+
done = true
|
|
55
|
+
process.stdout.write(JSON.stringify(decision))
|
|
56
|
+
process.exit(0)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Backstop: if anything hangs, return "ask" rather than letting the hook time out
|
|
60
|
+
// (which, with failClosed, would block the command).
|
|
61
|
+
setTimeout(() => respond(ask()), HARD_GUARD_MS).unref()
|
|
62
|
+
|
|
63
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
64
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi)
|
|
65
|
+
|
|
66
|
+
const withRetry = async (fn, attempts) => {
|
|
67
|
+
let lastError
|
|
68
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
69
|
+
try {
|
|
70
|
+
return await fn()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
lastError = error
|
|
73
|
+
if (i < attempts - 1) await sleep(300 * (i + 1))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw lastError
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const readStdin = async () => {
|
|
80
|
+
let raw = ''
|
|
81
|
+
for await (const chunk of process.stdin) raw += chunk
|
|
82
|
+
return raw
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const getMachineId = () => createHash('sha256').update(hostname()).digest('hex').slice(0, 8)
|
|
86
|
+
|
|
87
|
+
// Env first. CLI installs inject the key into the sibling mcp.json, so fall back to
|
|
88
|
+
// that. This lets the gate work even when Cursor's GUI does not pass the shell env.
|
|
89
|
+
const resolveApiKey = () => {
|
|
90
|
+
const fromEnv = process.env.PUSHARY_API_KEY
|
|
91
|
+
if (fromEnv) return fromEnv
|
|
92
|
+
try {
|
|
93
|
+
const mcpPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'mcp.json')
|
|
94
|
+
const auth = JSON.parse(readFileSync(mcpPath, 'utf-8'))?.mcpServers?.pushary?.headers?.Authorization ?? ''
|
|
95
|
+
const key = auth.replace(/^Bearer\s+/i, '').trim()
|
|
96
|
+
if (/^pk_[a-z0-9]+\.[a-z0-9]+$/i.test(key)) return key
|
|
97
|
+
} catch {}
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── MCP transport (JSON or SSE) ─────────────────────────────────────────────────
|
|
102
|
+
const parseMcpBody = (body, contentType) => {
|
|
103
|
+
if (contentType && contentType.includes('text/event-stream')) {
|
|
104
|
+
let last = null
|
|
105
|
+
for (const frame of body.split(/\r?\n\r?\n/)) {
|
|
106
|
+
const data = frame
|
|
107
|
+
.split(/\r?\n/)
|
|
108
|
+
.filter((line) => line.startsWith('data:'))
|
|
109
|
+
.map((line) => line.slice(5).trimStart())
|
|
110
|
+
.join('\n')
|
|
111
|
+
.trim()
|
|
112
|
+
if (!data) continue
|
|
113
|
+
try {
|
|
114
|
+
last = JSON.parse(data)
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
if (!last) throw new Error('empty SSE response')
|
|
118
|
+
return last
|
|
119
|
+
}
|
|
120
|
+
return JSON.parse(body)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const callTool = async (apiKey, name, args) => {
|
|
124
|
+
const response = await fetch(MCP_URL, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: {
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
Accept: 'application/json, text/event-stream',
|
|
129
|
+
Authorization: `Bearer ${apiKey}`,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method: 'tools/call', params: { name, arguments: args } }),
|
|
132
|
+
signal: AbortSignal.timeout(NET_TIMEOUT_MS),
|
|
133
|
+
})
|
|
134
|
+
const text = await response.text()
|
|
135
|
+
if (!response.ok) throw new Error(`Pushary MCP ${response.status}`)
|
|
136
|
+
const rpc = parseMcpBody(text, response.headers.get('content-type'))
|
|
137
|
+
if (rpc.error) throw new Error(rpc.error.message || 'Pushary MCP error')
|
|
138
|
+
const payload = rpc.result?.content?.[0]?.text
|
|
139
|
+
if (!payload) throw new Error('empty Pushary response')
|
|
140
|
+
return JSON.parse(payload)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const getJson = async (path, apiKey, timeoutMs) => {
|
|
144
|
+
const response = await fetch(`${BASE_URL}${path}`, {
|
|
145
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
146
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
147
|
+
})
|
|
148
|
+
if (!response.ok) throw new Error(`GET ${path} ${response.status}`)
|
|
149
|
+
return response.json()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Policy (mirrors @pushary/agent-hooks policy.ts) ──────────────────────────────
|
|
153
|
+
const isPolicyConfig = (d) =>
|
|
154
|
+
!!d && typeof d === 'object' && Array.isArray(d.policies) && typeof d.defaultTimeoutSeconds === 'number' && typeof d.defaultTimeoutAction === 'string'
|
|
155
|
+
|
|
156
|
+
const policyCacheFile = (apiKey) => join(tmpdir(), `pushary-policy-${createHash('sha256').update(apiKey).digest('hex').slice(0, 12)}.json`)
|
|
157
|
+
|
|
158
|
+
const getPolicy = async (apiKey) => {
|
|
159
|
+
const path = policyCacheFile(apiKey)
|
|
160
|
+
let stale = null
|
|
161
|
+
if (existsSync(path)) {
|
|
162
|
+
try {
|
|
163
|
+
const cached = JSON.parse(readFileSync(path, 'utf-8'))
|
|
164
|
+
if (isPolicyConfig(cached)) {
|
|
165
|
+
if (!cached._cachedAt || Date.now() - cached._cachedAt < POLICY_CACHE_TTL_MS) return cached
|
|
166
|
+
stale = cached
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const fresh = await withRetry(async () => {
|
|
172
|
+
const raw = await getJson('/api/mcp/policy', apiKey, POLICY_TIMEOUT_MS)
|
|
173
|
+
if (!isPolicyConfig(raw)) throw new Error('invalid policy')
|
|
174
|
+
return raw
|
|
175
|
+
}, 2)
|
|
176
|
+
try {
|
|
177
|
+
writeFileSync(path, JSON.stringify({ ...fresh, _cachedAt: Date.now() }), 'utf-8')
|
|
178
|
+
} catch {}
|
|
179
|
+
return fresh
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (stale) return stale
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const resolvePolicy = (config, toolName, modeOverride) => {
|
|
187
|
+
const base =
|
|
188
|
+
config.policies.find((p) => p.tool === toolName) ??
|
|
189
|
+
config.policies.find((p) => p.tool === '*') ??
|
|
190
|
+
{
|
|
191
|
+
tool: toolName,
|
|
192
|
+
timeoutSeconds: config.defaultTimeoutSeconds,
|
|
193
|
+
timeoutAction: config.defaultTimeoutAction,
|
|
194
|
+
mode: config.defaultMode ?? 'push_first',
|
|
195
|
+
pushFirstSeconds: config.defaultPushFirstSeconds ?? 20,
|
|
196
|
+
}
|
|
197
|
+
const effective = modeOverride ?? config.modeOverride
|
|
198
|
+
return effective ? { ...base, mode: effective } : base
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const APPROVAL_MODES = ['push_only', 'terminal_only', 'push_first', 'notify_only']
|
|
202
|
+
const fetchModeState = async (apiKey, sessionId) => {
|
|
203
|
+
try {
|
|
204
|
+
const path = sessionId ? `/api/mcp/mode?session=${encodeURIComponent(sessionId)}` : '/api/mcp/mode'
|
|
205
|
+
const data = await getJson(path, apiKey, MODE_TIMEOUT_MS)
|
|
206
|
+
const mode = data?.override?.mode
|
|
207
|
+
return { mode: APPROVAL_MODES.includes(mode) ? mode : null, kill: data?.kill === true }
|
|
208
|
+
} catch {
|
|
209
|
+
return { mode: null, kill: false }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── ask / wait ───────────────────────────────────────────────────────────────
|
|
214
|
+
const askArgs = (command, project, ident) => ({
|
|
215
|
+
question: `Allow this command?\n\n${command}`,
|
|
216
|
+
type: 'confirm',
|
|
217
|
+
context: `Cursor agent wants to run this in ${project}`,
|
|
218
|
+
agentName: ident.agentName,
|
|
219
|
+
sessionId: ident.sessionId,
|
|
220
|
+
machineId: ident.machineId,
|
|
221
|
+
toolName: 'Bash',
|
|
222
|
+
wait: false,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
|
|
226
|
+
while (Date.now() < deadlineMs) {
|
|
227
|
+
const remaining = clamp(deadlineMs - Date.now(), 1_000, WAIT_CHUNK_MS)
|
|
228
|
+
try {
|
|
229
|
+
const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: remaining })
|
|
230
|
+
if (answer?.answered) return answer
|
|
231
|
+
} catch {
|
|
232
|
+
if (Date.now() + POLL_GAP_MS >= deadlineMs) break
|
|
233
|
+
await sleep(POLL_GAP_MS)
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
if (Date.now() + POLL_GAP_MS >= deadlineMs) break
|
|
237
|
+
await sleep(POLL_GAP_MS)
|
|
238
|
+
}
|
|
239
|
+
return { answered: false }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fromTimeoutAction = (action, deniedReason) =>
|
|
243
|
+
action === 'approve' ? ALLOW : action === 'deny' ? deny(deniedReason) : ask()
|
|
244
|
+
|
|
245
|
+
const DENIED = 'The user denied this command via a Pushary push approval. Do not run it — propose an alternative or ask how to proceed.'
|
|
246
|
+
|
|
247
|
+
// push_only: wait up to the policy timeout, then apply the timeout action.
|
|
248
|
+
const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, timeoutAction) => {
|
|
249
|
+
let asked
|
|
250
|
+
try {
|
|
251
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', askArgs(command, project, ident)), 3)
|
|
252
|
+
} catch {
|
|
253
|
+
return fromTimeoutAction(timeoutAction, 'Push notification failed; denied per your Pushary policy.')
|
|
254
|
+
}
|
|
255
|
+
if (!asked?.correlationId) return ask()
|
|
256
|
+
|
|
257
|
+
const realMs = Math.max(timeoutSeconds, 1) * 1000
|
|
258
|
+
const cap = Math.min(realMs, MAX_BLOCK_MS)
|
|
259
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
|
|
260
|
+
if (answer.answered) return answer.value === 'yes' ? ALLOW : deny(DENIED)
|
|
261
|
+
|
|
262
|
+
// If Cursor's hook limit cut us off before the configured timeout, hand off to
|
|
263
|
+
// Cursor's own prompt rather than misapplying the policy's timeout action.
|
|
264
|
+
if (cap >= realMs) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
|
|
265
|
+
return ask()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// push_first: race the push for a short window, then fall back to Cursor's prompt.
|
|
269
|
+
const handlePushFirst = async (apiKey, command, project, ident, pushFirstSeconds) => {
|
|
270
|
+
let asked
|
|
271
|
+
try {
|
|
272
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', askArgs(command, project, ident)), 3)
|
|
273
|
+
} catch {
|
|
274
|
+
return ask()
|
|
275
|
+
}
|
|
276
|
+
if (!asked?.correlationId) return ask()
|
|
277
|
+
|
|
278
|
+
const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
|
|
279
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
|
|
280
|
+
if (answer.answered) return answer.value === 'yes' ? ALLOW : deny(DENIED)
|
|
281
|
+
return ask('Sent to your phone via Pushary — you can also approve here.')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// notify_only: fire an awareness notification, let Cursor's prompt decide.
|
|
285
|
+
const handleNotifyOnly = async (apiKey, command, project, ident) => {
|
|
286
|
+
try {
|
|
287
|
+
await callTool(apiKey, 'send_notification', {
|
|
288
|
+
title: 'Agent needs approval',
|
|
289
|
+
body: command.slice(0, 180),
|
|
290
|
+
agentName: ident.agentName,
|
|
291
|
+
sessionId: ident.sessionId,
|
|
292
|
+
machineId: ident.machineId,
|
|
293
|
+
})
|
|
294
|
+
} catch {}
|
|
295
|
+
return ask()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const main = async () => {
|
|
299
|
+
let input
|
|
300
|
+
try {
|
|
301
|
+
const raw = await readStdin()
|
|
302
|
+
input = raw.trim() ? JSON.parse(raw) : {}
|
|
303
|
+
} catch {
|
|
304
|
+
return respond(ask())
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const command = typeof input.command === 'string' ? input.command.trim() : ''
|
|
308
|
+
if (!command) return respond(ask())
|
|
309
|
+
|
|
310
|
+
const apiKey = resolveApiKey()
|
|
311
|
+
if (!apiKey) {
|
|
312
|
+
return respond(
|
|
313
|
+
ask('Pushary is not configured: set the PUSHARY_API_KEY environment variable (get a key at https://pushary.com) to route this approval to your phone.')
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const project = basename(input.cwd || process.cwd()) || 'workspace'
|
|
318
|
+
const sessionId = typeof input.conversation_id === 'string' ? input.conversation_id : undefined
|
|
319
|
+
const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId() }
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const [policy, modeState] = await Promise.all([getPolicy(apiKey), fetchModeState(apiKey, sessionId)])
|
|
323
|
+
|
|
324
|
+
if (modeState.kill) return respond(deny('Stopped by user — this agent was halted from Pushary. Do not run this command.'))
|
|
325
|
+
|
|
326
|
+
const tool = resolvePolicy(policy, 'Bash', modeState.mode)
|
|
327
|
+
if (tool.timeoutSeconds === 0 && tool.timeoutAction === 'approve') return respond(ALLOW)
|
|
328
|
+
|
|
329
|
+
switch (tool.mode) {
|
|
330
|
+
case 'terminal_only':
|
|
331
|
+
return respond(ask())
|
|
332
|
+
case 'notify_only':
|
|
333
|
+
return respond(await handleNotifyOnly(apiKey, command, project, ident))
|
|
334
|
+
case 'push_only':
|
|
335
|
+
return respond(await handlePushOnly(apiKey, command, project, ident, tool.timeoutSeconds, tool.timeoutAction))
|
|
336
|
+
case 'push_first':
|
|
337
|
+
default:
|
|
338
|
+
return respond(await handlePushFirst(apiKey, command, project, ident, tool.pushFirstSeconds))
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
process.stderr.write(`[pushary-gate] ${error?.message ?? error}\n`)
|
|
342
|
+
return respond(ask())
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
main().catch((error) => {
|
|
347
|
+
process.stderr.write(`[pushary-gate] fatal: ${error?.message ?? error}\n`)
|
|
348
|
+
respond(ask())
|
|
349
|
+
})
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pushary
|
|
3
|
+
description: Push notifications and human-in-the-loop for AI coding agents. Send alerts when tasks finish, ask questions (yes/no, multiple choice, or free text) via push, and get answers from the user's lock screen. Use these tools proactively — do not wait for the user to ask for notifications. Works with Cursor, Claude Code, Windsurf, Hermes, and any MCP client.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pushary — Push Notifications for AI Agents
|
|
7
|
+
|
|
8
|
+
Pushary is an MCP server that gives you three capabilities:
|
|
9
|
+
|
|
10
|
+
1. **Send push notifications** to the user's phone or desktop when a task finishes or an error occurs.
|
|
11
|
+
2. **Ask questions** via push — yes/no, multiple choice, or free text — and wait for the user's answer.
|
|
12
|
+
3. **Send rich context notifications** with file changes, error details, next steps, and embedded questions.
|
|
13
|
+
|
|
14
|
+
Use these tools proactively. Do not wait for the user to ask for notifications.
|
|
15
|
+
|
|
16
|
+
## When to Use
|
|
17
|
+
|
|
18
|
+
**Send a notification when:**
|
|
19
|
+
- You finish a task that took 3 or more steps — use `context.type = "task_complete"`
|
|
20
|
+
- A build, test suite, or deployment fails — use `context.type = "error"` with `errorMessage`
|
|
21
|
+
- A long-running process completes (migration, refactor, generation)
|
|
22
|
+
- A status update is worth sharing — use `context.type = "info"`
|
|
23
|
+
|
|
24
|
+
**Ask with type "confirm" when:**
|
|
25
|
+
- You need confirmation before a destructive or irreversible action
|
|
26
|
+
- Binary decision: proceed or abort
|
|
27
|
+
|
|
28
|
+
**Ask with type "select" when:**
|
|
29
|
+
- Multiple implementation approaches exist (2-6 options)
|
|
30
|
+
- The user needs to pick from a known set
|
|
31
|
+
|
|
32
|
+
**Ask with type "input" when:**
|
|
33
|
+
- You need a name, path, value, or free-text decision
|
|
34
|
+
- The options cannot be enumerated in advance
|
|
35
|
+
|
|
36
|
+
**Do NOT notify when:**
|
|
37
|
+
- The task is trivial or single-step
|
|
38
|
+
- The question can be answered from context without user input
|
|
39
|
+
- You already sent 3 notifications for the current task (unless the user explicitly asked for more)
|
|
40
|
+
|
|
41
|
+
## Setup
|
|
42
|
+
|
|
43
|
+
Run the CLI setup (recommended — configures MCP, hooks, permissions, and skill in one step):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx @pushary/agent-hooks@latest setup
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or add Pushary manually to your MCP configuration:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"pushary": {
|
|
55
|
+
"type": "http",
|
|
56
|
+
"url": "https://pushary.com/api/mcp/mcp",
|
|
57
|
+
"headers": {
|
|
58
|
+
"Authorization": "Bearer YOUR_API_KEY"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Sign up at https://pushary.com/sign-up?from=ai-coding to get your API key.
|
|
66
|
+
|
|
67
|
+
After setup, verify with:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx @pushary/agent-hooks@latest doctor
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Tools
|
|
74
|
+
|
|
75
|
+
### send_notification
|
|
76
|
+
|
|
77
|
+
Send a one-way push notification to the user. Optionally include structured context for a rich detail page.
|
|
78
|
+
|
|
79
|
+
**Parameters:**
|
|
80
|
+
|
|
81
|
+
| Name | Type | Required | Description |
|
|
82
|
+
|------|------|----------|-------------|
|
|
83
|
+
| title | string | Yes | Notification title (max 100 chars, aim for under 60) |
|
|
84
|
+
| body | string | Yes | Notification body (max 500 chars, aim for under 200) |
|
|
85
|
+
| url | string | No | URL opened when tapped. Ignored if context is provided. |
|
|
86
|
+
| agentName | string | No | Identifies which agent sent this (e.g., "Claude Code - myproject") |
|
|
87
|
+
| iconUrl | string | No | Custom notification icon URL |
|
|
88
|
+
| imageUrl | string | No | Large image shown in the notification |
|
|
89
|
+
| subscriberIds | string[] | No | Target specific subscriber IDs |
|
|
90
|
+
| externalIds | string[] | No | Target by external IDs |
|
|
91
|
+
| tags | string[] | No | Target by subscriber tags |
|
|
92
|
+
| context | object | No | Structured context for a rich detail page (see below) |
|
|
93
|
+
|
|
94
|
+
**Context object:**
|
|
95
|
+
|
|
96
|
+
| Name | Type | Description |
|
|
97
|
+
|------|------|-------------|
|
|
98
|
+
| type | "task_complete" / "error" / "info" | The kind of notification |
|
|
99
|
+
| summary | string | Short summary of what happened |
|
|
100
|
+
| details | string[] | Bullet-point details |
|
|
101
|
+
| filesChanged | string[] | List of files that were changed |
|
|
102
|
+
| errorMessage | string | Error message (for error type) |
|
|
103
|
+
| errorFile | string | File path where the error occurred |
|
|
104
|
+
| nextSteps | string | Suggested next steps for the user |
|
|
105
|
+
| askQuestion | object | Embed a decision prompt in the notification (see below) |
|
|
106
|
+
|
|
107
|
+
**Embedded askQuestion:**
|
|
108
|
+
|
|
109
|
+
| Name | Type | Description |
|
|
110
|
+
|------|------|-------------|
|
|
111
|
+
| question | string | A follow-up question shown below the context |
|
|
112
|
+
| type | "confirm" / "select" / "input" | Question type (default: confirm) |
|
|
113
|
+
| options | string[] | Options for select type (2-6 items) |
|
|
114
|
+
|
|
115
|
+
When `askQuestion` is provided, the response includes a `linkedCorrelationId` you pass to `wait_for_answer`.
|
|
116
|
+
|
|
117
|
+
**Example — task completed with context:**
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"title": "Refactoring complete",
|
|
122
|
+
"body": "Extracted 3 shared components across 12 files",
|
|
123
|
+
"agentName": "Claude Code - pushary repo",
|
|
124
|
+
"context": {
|
|
125
|
+
"type": "task_complete",
|
|
126
|
+
"summary": "Extracted shared Button, Modal, and Card components from 12 files",
|
|
127
|
+
"filesChanged": ["src/components/Button.tsx", "src/components/Modal.tsx", "src/components/Card.tsx"],
|
|
128
|
+
"nextSteps": "Run the test suite to verify no regressions"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Example — error with embedded question:**
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"title": "Build failed",
|
|
138
|
+
"body": "TypeScript error in auth.ts:42",
|
|
139
|
+
"agentName": "Claude Code - api-server",
|
|
140
|
+
"context": {
|
|
141
|
+
"type": "error",
|
|
142
|
+
"errorMessage": "Type 'string' is not assignable to type 'AuthToken'",
|
|
143
|
+
"errorFile": "src/auth.ts:42",
|
|
144
|
+
"summary": "The auth token type changed upstream and this file needs updating",
|
|
145
|
+
"askQuestion": {
|
|
146
|
+
"question": "Should I update the type or revert the upstream change?",
|
|
147
|
+
"type": "select",
|
|
148
|
+
"options": ["Update the type in auth.ts", "Revert the upstream change", "Skip for now"]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### ask_user
|
|
155
|
+
|
|
156
|
+
Send a question to the user via push notification and wait for their answer. By default, this tool **blocks** until the user responds or the timeout is reached — no need to call `wait_for_answer` separately.
|
|
157
|
+
|
|
158
|
+
**Parameters:**
|
|
159
|
+
|
|
160
|
+
| Name | Type | Required | Description |
|
|
161
|
+
|------|------|----------|-------------|
|
|
162
|
+
| question | string | Yes | The question to ask (max 500 chars) |
|
|
163
|
+
| type | "confirm" / "select" / "input" | No | Question type (default: confirm) |
|
|
164
|
+
| options | string[] | No | Choices for select type (2-6 options). Required when type is select. |
|
|
165
|
+
| placeholder | string | No | Placeholder text for input type (max 200 chars) |
|
|
166
|
+
| context | string | No | What the agent is working on, shown above the question (max 500 chars) |
|
|
167
|
+
| wait | boolean | No | Wait for the answer before returning (default: true). Set false for manual polling. |
|
|
168
|
+
| timeoutMs | integer | No | Max wait time in ms (max 55000). Uses site policy if omitted. |
|
|
169
|
+
| agentName | string | No | Identifies which agent is asking. Format: "{Agent} - {project}" (e.g., "Claude Code - myproject") |
|
|
170
|
+
| callbackUrl | string | No | Webhook URL to POST the answer to when the user responds |
|
|
171
|
+
| subscriberIds | string[] | No | Target specific subscriber IDs |
|
|
172
|
+
| externalIds | string[] | No | Target by external IDs |
|
|
173
|
+
| tags | string[] | No | Target by subscriber tags |
|
|
174
|
+
|
|
175
|
+
**Returns (when wait=true, default):**
|
|
176
|
+
- `{ "answered": true, "value": "yes", "correlationId": "uuid" }` — user responded
|
|
177
|
+
- `{ "answered": false, "timedOut": true, "correlationId": "uuid" }` — timeout reached
|
|
178
|
+
|
|
179
|
+
**Returns (when wait=false):**
|
|
180
|
+
- `{ "correlationId": "uuid", "status": "pending", "expiresInSeconds": 600 }` — use `wait_for_answer` to poll
|
|
181
|
+
|
|
182
|
+
**Example — confirm (yes/no):**
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"question": "Delete the 3 unused migration files?",
|
|
187
|
+
"type": "confirm",
|
|
188
|
+
"context": "Cleaning up old database migrations in db/migrate/",
|
|
189
|
+
"agentName": "Claude Code - myproject"
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Example — select (multiple choice):**
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"question": "Which auth strategy should I use?",
|
|
198
|
+
"type": "select",
|
|
199
|
+
"options": ["JWT tokens", "Session cookies", "OAuth2 + PKCE"],
|
|
200
|
+
"context": "Setting up authentication for the new API endpoints",
|
|
201
|
+
"agentName": "Claude Code - api-server"
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Example — input (free text):**
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"question": "What should the new API endpoint path be?",
|
|
210
|
+
"type": "input",
|
|
211
|
+
"placeholder": "/api/v2/...",
|
|
212
|
+
"context": "Creating a new REST endpoint for user preferences",
|
|
213
|
+
"agentName": "Cursor - frontend"
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### wait_for_answer
|
|
218
|
+
|
|
219
|
+
Poll for the user's response to a question sent via `ask_user` with `wait: false`. Not needed when using the default blocking mode.
|
|
220
|
+
|
|
221
|
+
**Parameters:**
|
|
222
|
+
|
|
223
|
+
| Name | Type | Required | Description |
|
|
224
|
+
|------|------|----------|-------------|
|
|
225
|
+
| correlationId | string (uuid) | Yes | The correlationId from ask_user |
|
|
226
|
+
| timeoutMs | integer | No | How long to wait (default 30000, max 55000) |
|
|
227
|
+
|
|
228
|
+
**Returns:**
|
|
229
|
+
- `{ "answered": true, "value": "yes" }` — user responded
|
|
230
|
+
- `{ "answered": false }` — timeout reached, no answer yet
|
|
231
|
+
|
|
232
|
+
### cancel_question
|
|
233
|
+
|
|
234
|
+
Cancel a pending question so it can no longer be answered. Use when the question becomes irrelevant (e.g., you found the answer another way or the user responded in chat).
|
|
235
|
+
|
|
236
|
+
**Parameters:**
|
|
237
|
+
|
|
238
|
+
| Name | Type | Required | Description |
|
|
239
|
+
|------|------|----------|-------------|
|
|
240
|
+
| correlationId | string (uuid) | Yes | The correlationId of the question to cancel |
|
|
241
|
+
|
|
242
|
+
## Permission Gating (REQUIRED)
|
|
243
|
+
|
|
244
|
+
Before executing any of the following, you MUST call `ask_user` with type "confirm" and wait for approval. Do NOT proceed without an explicit "yes" from the user:
|
|
245
|
+
|
|
246
|
+
- File deletion (`rm`, `unlink`, any destructive file operation)
|
|
247
|
+
- Database mutations (`DROP`, `DELETE`, `TRUNCATE`, migrations)
|
|
248
|
+
- Deployment commands (`deploy`, `push`, `publish`, `release`)
|
|
249
|
+
- System administration (`systemctl`, `service`, package install/remove)
|
|
250
|
+
- Git operations that rewrite history (`reset --hard`, `push --force`, `rebase`)
|
|
251
|
+
- Network configuration changes (firewall, DNS, proxy)
|
|
252
|
+
- Any command the user has flagged as dangerous
|
|
253
|
+
|
|
254
|
+
If `ask_user` returns `answered: false`, do NOT execute the command. Send a notification that the operation was skipped due to no response.
|
|
255
|
+
|
|
256
|
+
This is not optional. Treat it as a hard constraint, not a suggestion.
|
|
257
|
+
|
|
258
|
+
## Human-in-the-Loop Flow
|
|
259
|
+
|
|
260
|
+
One tool call — `ask_user` blocks and returns the answer:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
result = ask_user({
|
|
264
|
+
question: "Which auth strategy should I use?",
|
|
265
|
+
type: "select",
|
|
266
|
+
options: ["JWT tokens", "Session cookies", "OAuth2 + PKCE"],
|
|
267
|
+
context: "Setting up authentication for the new API",
|
|
268
|
+
agentName: "Claude Code - myproject"
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
if result.answered:
|
|
272
|
+
// result.value = "JWT tokens" — proceed with the chosen approach
|
|
273
|
+
else:
|
|
274
|
+
// user did not respond — pick the safe default or notify and skip
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
If the user answers in chat before the push response arrives, continue normally and call `cancel_question` with the `correlationId` to clean up.
|
|
278
|
+
|
|
279
|
+
## Identifying Your Agent
|
|
280
|
+
|
|
281
|
+
Always pass `agentName` when you are one of multiple possible agents the user may be running. The user sees this in the notification title to know which agent is asking.
|
|
282
|
+
|
|
283
|
+
**Format:** `{Agent Type} - {project or context}`
|
|
284
|
+
|
|
285
|
+
**Examples:**
|
|
286
|
+
- `"Claude Code - pushary repo"`
|
|
287
|
+
- `"Hermes - daily-briefing"`
|
|
288
|
+
- `"Cursor - frontend refactor"`
|
|
289
|
+
|
|
290
|
+
## Notification Etiquette
|
|
291
|
+
|
|
292
|
+
- **Titles under 60 characters.** They get truncated on phone lock screens.
|
|
293
|
+
- **Bodies under 200 characters.** Concise summaries, not full explanations.
|
|
294
|
+
- **Max 3 notifications per task** unless the user explicitly requests more.
|
|
295
|
+
- **Use context for detail.** Put file lists, error traces, and next steps in the context object — not the notification body.
|
|
296
|
+
- **Write questions as if talking to a busy person.** The user is on their phone, possibly away from their computer. Be specific: "Delete the 3 unused migration files?" is better than "Should I clean up?"
|
|
297
|
+
- **Pick the right question type.** Use confirm for binary decisions, select when options are known, input when they are not.
|
|
@@ -24,6 +24,7 @@ var CLAUDE_SETTINGS_LOCAL = join(homedir(), ".claude", "settings.local.json");
|
|
|
24
24
|
var CLAUDE_JSON = join(homedir(), ".claude.json");
|
|
25
25
|
var SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
|
|
26
26
|
var CURSOR_MCP = join(".cursor", "mcp.json");
|
|
27
|
+
var CURSOR_PLUGIN_DIR = join(homedir(), ".cursor", "plugins", "local", "pushary");
|
|
27
28
|
var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
|
|
28
29
|
var readJson = (path) => {
|
|
29
30
|
try {
|
|
@@ -86,6 +87,12 @@ var main = async () => {
|
|
|
86
87
|
} else {
|
|
87
88
|
console.log(` ${skip} Cursor MCP config ${dim("(not found)")}`);
|
|
88
89
|
}
|
|
90
|
+
if (existsSync(CURSOR_PLUGIN_DIR)) {
|
|
91
|
+
rmSync(CURSOR_PLUGIN_DIR, { recursive: true });
|
|
92
|
+
console.log(` ${check} Cursor plugin ${dim("(removed from ~/.cursor/plugins/local)")}`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(` ${skip} Cursor plugin ${dim("(not installed)")}`);
|
|
95
|
+
}
|
|
89
96
|
if (existsSync(SKILL_DIR)) {
|
|
90
97
|
rmSync(SKILL_DIR, { recursive: true });
|
|
91
98
|
console.log(` ${check} Skill directory ${dim("(removed)")}`);
|
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
} from "../chunk-IBWCHA5M.js";
|
|
14
14
|
|
|
15
15
|
// bin/pushary-setup.ts
|
|
16
|
-
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from "fs";
|
|
17
|
-
import { join, dirname } from "path";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, cpSync, rmSync } from "fs";
|
|
17
|
+
import { join, dirname, basename } from "path";
|
|
18
18
|
import { homedir } from "os";
|
|
19
19
|
import { execSync } from "child_process";
|
|
20
20
|
import { checkbox, input, confirm } from "@inquirer/prompts";
|
|
@@ -176,8 +176,7 @@ var printConnectInstructions = async (apiKey) => {
|
|
|
176
176
|
// bin/pushary-setup.ts
|
|
177
177
|
var CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
|
|
178
178
|
var CLAUDE_JSON = join(homedir(), ".claude.json");
|
|
179
|
-
var
|
|
180
|
-
var CURSOR_RULES_DIR = join(".cursor", "rules");
|
|
179
|
+
var CURSOR_PLUGIN_DIR = join(homedir(), ".cursor", "plugins", "local", "pushary");
|
|
181
180
|
var CLAUDE_SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
|
|
182
181
|
var CODEX_SKILL_DIR = join(homedir(), ".codex", "skills", "pushary");
|
|
183
182
|
var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
|
|
@@ -297,19 +296,6 @@ var installSkillToDir = async (dir, label) => {
|
|
|
297
296
|
writeFileSync(join(dir, "SKILL.md"), content, "utf-8");
|
|
298
297
|
});
|
|
299
298
|
};
|
|
300
|
-
var installCursorRule = async () => {
|
|
301
|
-
await spinner("Installing Pushary rules", async () => {
|
|
302
|
-
const content = await fetchSkillContent();
|
|
303
|
-
const body = content.replace(/^---[\s\S]*?---\n*/m, "");
|
|
304
|
-
const mdc = `---
|
|
305
|
-
alwaysApply: true
|
|
306
|
-
---
|
|
307
|
-
|
|
308
|
-
${body}`;
|
|
309
|
-
if (!existsSync(CURSOR_RULES_DIR)) mkdirSync(CURSOR_RULES_DIR, { recursive: true });
|
|
310
|
-
writeFileSync(join(CURSOR_RULES_DIR, "pushary.mdc"), mdc, "utf-8");
|
|
311
|
-
});
|
|
312
|
-
};
|
|
313
299
|
var setupClaudeCode = async (apiKey) => {
|
|
314
300
|
console.log(`
|
|
315
301
|
${bold2("Setting up Claude Code")}
|
|
@@ -469,22 +455,45 @@ var setupCodex = async (_apiKey) => {
|
|
|
469
455
|
console.log(` ${dim2("\u2022")} Auto-allowed tools: no permission prompts for Pushary MCP calls`);
|
|
470
456
|
console.log(` ${dim2("\u2022")} Notify handler: captures turn completions and approval requests`);
|
|
471
457
|
};
|
|
458
|
+
var resolveBundledPlugin = () => {
|
|
459
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
460
|
+
const candidates = [
|
|
461
|
+
join(dir, "..", "..", "data", "cursor-plugin"),
|
|
462
|
+
join(dir, "..", "data", "cursor-plugin"),
|
|
463
|
+
join(dir, "..", "..", "..", "cursor-plugin"),
|
|
464
|
+
join(dir, "..", "..", "cursor-plugin")
|
|
465
|
+
];
|
|
466
|
+
return candidates.find((p) => existsSync(join(p, ".cursor-plugin", "plugin.json"))) ?? null;
|
|
467
|
+
};
|
|
472
468
|
var setupCursor = async (apiKey) => {
|
|
473
469
|
console.log(`
|
|
474
470
|
${bold2("Setting up Cursor")}
|
|
475
471
|
`);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
};
|
|
484
|
-
|
|
485
|
-
|
|
472
|
+
const source = resolveBundledPlugin();
|
|
473
|
+
if (!source) throw new Error("bundled Cursor plugin not found in this package");
|
|
474
|
+
await spinner("Installing Pushary plugin", async () => {
|
|
475
|
+
rmSync(CURSOR_PLUGIN_DIR, { recursive: true, force: true });
|
|
476
|
+
cpSync(source, CURSOR_PLUGIN_DIR, {
|
|
477
|
+
recursive: true,
|
|
478
|
+
filter: (p) => !["tools", "node_modules", ".git", ".DS_Store"].includes(basename(p))
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
await spinner("Linking your API key", async () => {
|
|
482
|
+
const mcpPath = join(CURSOR_PLUGIN_DIR, "mcp.json");
|
|
483
|
+
const mcp = readJson(mcpPath);
|
|
484
|
+
const servers = mcp.mcpServers ?? {};
|
|
485
|
+
if (servers.pushary) {
|
|
486
|
+
servers.pushary.headers = { ...servers.pushary.headers, Authorization: `Bearer ${apiKey}` };
|
|
487
|
+
mcp.mcpServers = servers;
|
|
488
|
+
writeJson(mcpPath, mcp);
|
|
489
|
+
}
|
|
486
490
|
});
|
|
487
|
-
|
|
491
|
+
console.log();
|
|
492
|
+
console.log(` ${dim2("What this configured:")}`);
|
|
493
|
+
console.log(` ${dim2("\u2022")} Plugin installed to ~/.cursor/plugins/local/pushary`);
|
|
494
|
+
console.log(` ${dim2("\u2022")} MCP tools, the always-on rule, the skill, and the permission gate`);
|
|
495
|
+
console.log(` ${dim2("\u2022")} Risky shell commands route to push approval before they run`);
|
|
496
|
+
console.log(` ${dim2("\u2022")} Restart Cursor (or run Developer: Reload Window) to load it`);
|
|
488
497
|
};
|
|
489
498
|
var saveApiKey = async (apiKey) => {
|
|
490
499
|
await spinner("Saving API key to shell profile", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pushary/agent-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Permission hooks for AI coding agents: route tool approvals through Pushary push notifications",
|
|
5
5
|
"author": "Pushary <business@pushary.com>",
|
|
6
6
|
"homepage": "https://pushary.com",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"data"
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
|
-
"build": "tsup",
|
|
33
|
+
"build": "node scripts/bundle-plugin.mjs && tsup",
|
|
34
34
|
"dev": "tsup --watch",
|
|
35
35
|
"test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/mcp-http.test.ts && bun test src/retry.test.ts && bun test src/validate.test.ts && bun test src/policy.test.ts && bun test src/npm.test.ts && bun test src/identity.test.ts && bun test src/pending.test.ts && bun test src/events.test.ts && bun test src/hook.test.ts"
|
|
36
36
|
},
|