@pushary/agent-hooks 0.11.1 → 0.13.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.
@@ -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.
@@ -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,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "pushary": {
4
+ "type": "http",
5
+ "url": "https://pushary.com/api/mcp/mcp",
6
+ "headers": {
7
+ "Authorization": "Bearer ${PUSHARY_API_KEY}"
8
+ }
9
+ }
10
+ }
11
+ }
@@ -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
+ })