@pushary/agent-hooks 0.95.2 → 0.95.5
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/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/data/cursor-plugin/.cursor-plugin/plugin.json +1 -1
- package/data/cursor-plugin/CHANGELOG.md +5 -0
- package/data/cursor-plugin/README.md +6 -8
- package/data/cursor-plugin/hooks/hooks.json +56 -1
- package/data/cursor-plugin/scripts/pushary-gate.mjs +120 -71
- package/data/vscode-plugin/.claude-plugin/plugin.json +1 -1
- package/data/vscode-plugin/CHANGELOG.md +5 -0
- package/data/vscode-plugin/README.md +7 -15
- package/data/vscode-plugin/hooks/hooks.json +49 -0
- package/data/vscode-plugin/scripts/pushary-gate.mjs +208 -88
- package/dist/bin/pushary-bell-hook.js +20 -22
- package/dist/bin/pushary-bell.js +9 -11
- package/dist/bin/pushary-claude.js +58 -46
- package/dist/bin/pushary-clean.js +21 -20
- package/dist/bin/pushary-codex-bridge.js +120 -25
- package/dist/bin/pushary-codex-hook.js +43 -63
- package/dist/bin/pushary-codex.js +11 -10
- package/dist/bin/pushary-connect.js +8 -10
- package/dist/bin/pushary-cowork.js +5 -5
- package/dist/bin/pushary-daemon.js +26 -28
- package/dist/bin/pushary-disconnect.js +11 -10
- package/dist/bin/pushary-doctor.js +25 -25
- package/dist/bin/pushary-elicitation-hook.js +30 -31
- package/dist/bin/pushary-gemini-bridge.js +9 -11
- package/dist/bin/pushary-gemini-hook.js +45 -79
- package/dist/bin/pushary-hook.js +27 -29
- package/dist/bin/pushary-login.js +57 -31
- package/dist/bin/pushary-logout.js +6 -8
- package/dist/bin/pushary-mode.js +5 -5
- package/dist/bin/pushary-notification-hook.js +22 -24
- package/dist/bin/pushary-opencode-hook.js +38 -74
- package/dist/bin/pushary-permission-denied-hook.js +25 -27
- package/dist/bin/pushary-permission-hook.js +25 -27
- package/dist/bin/pushary-post-hook.js +22 -24
- package/dist/bin/pushary-prompt-hook.js +22 -24
- package/dist/bin/pushary-session-end-hook.js +22 -24
- package/dist/bin/pushary-session-start-hook.js +25 -27
- package/dist/bin/pushary-setup.js +78 -55
- package/dist/bin/pushary-stats.js +4 -4
- package/dist/bin/pushary-status.js +10 -12
- package/dist/bin/pushary-stop-hook.js +22 -24
- package/dist/bin/pushary-stopfailure-hook.js +22 -24
- package/dist/bin/pushary-suggestions.js +2 -2
- package/dist/bin/pushary-transcript-register.js +18 -20
- package/dist/bin/pushary-transcripts.js +7 -9
- package/dist/bin/pushary-upgrade.js +17 -19
- package/dist/bin/pushary-wait.js +5 -5
- package/dist/bin/pushary.js +1 -1
- package/dist/{chunk-7LUFYRH4.js → chunk-2D7BAOMY.js} +7 -7
- package/dist/{chunk-3ZUTNTZA.js → chunk-2ZTL3PGN.js} +1 -1
- package/dist/{chunk-EPFDN2ZT.js → chunk-32RMQO5Z.js} +1 -1
- package/dist/{chunk-EZIEXHYM.js → chunk-3AJIK5AO.js} +5 -2
- package/dist/{chunk-IPHOUBTU.js → chunk-4PX4QK2Z.js} +1 -1
- package/dist/{chunk-UW4LWAWY.js → chunk-4RF52LSU.js} +8 -8
- package/dist/{chunk-UOTSWS5N.js → chunk-67YNQ3QA.js} +43 -98
- package/dist/{chunk-G4TL4YWH.js → chunk-6AEM5RCS.js} +1 -1
- package/dist/{chunk-G24SEVCI.js → chunk-6VANQN5Q.js} +3 -7
- package/dist/{chunk-CKOLDI47.js → chunk-BDI2MXTT.js} +2 -2
- package/dist/{chunk-YKLCWWEI.js → chunk-BMANRGJQ.js} +1 -1
- package/dist/{chunk-2XUPP4WM.js → chunk-CUEZMPLJ.js} +1 -1
- package/dist/{chunk-A4R6BZCK.js → chunk-D62EONG3.js} +1 -1
- package/dist/{chunk-ZDAT5KMU.js → chunk-D6PRC3YQ.js} +1 -1
- package/dist/{chunk-MOY5X6HJ.js → chunk-DLLAWCQB.js} +1 -1
- package/dist/{chunk-DSTLA2TC.js → chunk-GOPIMNVC.js} +1 -1
- package/dist/{chunk-CH6DTGQR.js → chunk-HBHEX3JQ.js} +3 -3
- package/dist/{chunk-DNVDKHQD.js → chunk-HCMM3BOW.js} +226 -58
- package/dist/{chunk-76XICHYT.js → chunk-HGUZOUB2.js} +4 -4
- package/dist/{chunk-E3NF5CL2.js → chunk-HQIVR7MM.js} +6 -3
- package/dist/{chunk-JDFB3YKG.js → chunk-I6GZX3FT.js} +3 -3
- package/dist/{chunk-3OXHYPZG.js → chunk-IM2A5H33.js} +4 -1
- package/dist/{chunk-V27AHID3.js → chunk-IVQMNLSA.js} +3 -14
- package/dist/{chunk-4LL6LG5X.js → chunk-KAVDAZ7A.js} +43 -1
- package/dist/chunk-KBITFQST.js +6 -0
- package/dist/{chunk-AMZJ3CKW.js → chunk-KI4PA7MQ.js} +1 -1
- package/dist/{chunk-IAEXPTXA.js → chunk-N4VNLISN.js} +2 -2
- package/dist/{chunk-VX3BWMC5.js → chunk-OEKFD5BC.js} +1 -1
- package/dist/{chunk-QF4RI5CD.js → chunk-OI3DJA7N.js} +1 -1
- package/dist/{chunk-WB2C7VPF.js → chunk-OW6AZDY2.js} +3 -3
- package/dist/{chunk-4XXUHWIK.js → chunk-QHWXUNMN.js} +1 -1
- package/dist/{chunk-M7WWEFG6.js → chunk-QQGNFHIK.js} +2 -2
- package/dist/{chunk-YJAQRKH5.js → chunk-RLXVW4AM.js} +1 -1
- package/dist/{chunk-GUVCYYDK.js → chunk-S5CMLR35.js} +94 -15
- package/dist/{chunk-EUAQ4YAN.js → chunk-UCS65VVR.js} +1 -1
- package/dist/{chunk-WM5XMCID.js → chunk-VOSGZ6PH.js} +4 -4
- package/dist/{chunk-QUE76J4H.js → chunk-VTO3TNIB.js} +1 -1
- package/dist/{chunk-6FAHPGCZ.js → chunk-WDKHZRAS.js} +183 -25
- package/dist/{chunk-5GLPYGKS.js → chunk-WRDLHR2F.js} +2 -2
- package/dist/{chunk-RVSEGFPD.js → chunk-XLOELLAS.js} +9 -4
- package/dist/chunk-XS33QBJA.js +112 -0
- package/dist/{chunk-FQUV5KTM.js → chunk-Z2UWFEJW.js} +8 -8
- package/dist/src/index.d.ts +8 -1
- package/dist/src/index.js +11 -12
- package/package.json +1 -1
- package/dist/chunk-BC3VCZ3E.js +0 -86
- package/dist/chunk-RER3FRTD.js +0 -265
- package/dist/chunk-X7YWD6IS.js +0 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.95.5
|
|
4
|
+
|
|
5
|
+
- Preserve the full approval window while allowing the final poll time to return; keep late denials and cancellation authoritative.
|
|
6
|
+
- Bundle corrected VS Code read classification and Cursor file-gate fallback, including native shell/MCP prompts when a gate script is missing.
|
|
7
|
+
- Share Claude hook ownership with the server to avoid duplicate decisions while retaining native-mode handoff.
|
|
8
|
+
- Keep supported-agent setup guidance and policy scope aligned with the shared agent manifest.
|
|
9
|
+
|
|
10
|
+
## 0.95.4
|
|
11
|
+
|
|
12
|
+
- Keep inactive/offline login results truthful, with one JSON result and no duplicate key minting to recheck readiness.
|
|
13
|
+
- Preserve authentication blocks until readiness is verified; recover the verified saved account while respecting explicit control off.
|
|
14
|
+
- Keep setup and CLI help accurate about approval delivery modes.
|
|
15
|
+
|
|
16
|
+
## 0.95.3
|
|
17
|
+
|
|
18
|
+
- Verify account readiness after phone pairing before activating approvals or starting managed services.
|
|
19
|
+
- Preserve useful configuration while offline, with consistent unverified output and exit codes instead of a successful activation claim.
|
|
20
|
+
- Reuse the saved key when setup is rerun after payment or connectivity recovers; preserve explicit phone-start opt-outs.
|
|
21
|
+
|
|
3
22
|
## 0.95.2
|
|
4
23
|
|
|
5
24
|
- Add `pushary cowork` and connector-only setup aliases for Claude Desktop, Chat and Cowork, without acquiring a CLI key or installing local hooks.
|
package/README.md
CHANGED
|
@@ -195,7 +195,7 @@ bell Free local bell when an agent finishes or needs you. No account, no
|
|
|
195
195
|
doctor Verify your Pushary installation is working
|
|
196
196
|
disconnect Turn off one agent (claude|codex|gemini|cursor)
|
|
197
197
|
clean Remove all Pushary configuration (--yes for non-interactive)
|
|
198
|
-
mode
|
|
198
|
+
mode Choose where approval requests appear
|
|
199
199
|
wait Show or set the "wait for your phone" ladder (pushary wait 45)
|
|
200
200
|
stats Show the approval moments your agents hit while not connected
|
|
201
201
|
suggestions List rules mined from your own approvals
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "pushary",
|
|
3
3
|
"displayName": "Pushary — Control Panel for AI Agents",
|
|
4
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.2.
|
|
5
|
+
"version": "0.2.2",
|
|
6
6
|
"author": { "name": "Pushary", "email": "business@pushary.com" },
|
|
7
7
|
"homepage": "https://pushary.com",
|
|
8
8
|
"repository": "https://github.com/Pushary/cursor-plugin",
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Allow file edits when the server explicitly says the action is not gated.
|
|
6
|
+
- Keep unresolved file checks denied with an accurate retry message, while shell/MCP hooks retain the native prompt.
|
|
7
|
+
|
|
3
8
|
## 0.2.0
|
|
4
9
|
|
|
5
10
|
The gate stopped carrying its own copy of the policy engine.
|
|
@@ -18,9 +18,9 @@ There are three things.
|
|
|
18
18
|
|
|
19
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
20
|
|
|
21
|
-
2. Ask. The agent asks you questions through push: yes or no, multiple choice, or free text. It waits for your answer.
|
|
21
|
+
2. Ask. The agent asks you questions through push: yes or no, multiple choice, or free text. It waits for your answer. The bundled instructions guide the agent to use Pushary for questions. Native editor question dialogs are not automatically intercepted.
|
|
22
22
|
|
|
23
|
-
3. Gate.
|
|
23
|
+
3. Gate. Shell commands, file writes/edits/deletes, and third-party MCP calls reach your Pushary policy before execution. The policy can approve, deny, or wait for your answer. Cancelled or unverifiable decisions stop the action. Cursor’s generic file-tool hook does not enforce `ask`, so an unresolved file action is denied. File actions explicitly marked `not_gated` by the server proceed; shell/MCP hooks can hand off to Cursor’s prompt.
|
|
24
24
|
|
|
25
25
|
## Install
|
|
26
26
|
|
|
@@ -54,7 +54,7 @@ Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for ever
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
The plugin and CLI installer use the same decision service. Manual MCP-only configuration provides questions and notifications, but does not install approval or activity hooks.
|
|
58
58
|
|
|
59
59
|
## Set your API key
|
|
60
60
|
|
|
@@ -78,16 +78,14 @@ macOS GUI apps do not read `.zshrc`. If Settings > MCP shows pushary in red afte
|
|
|
78
78
|
| MCP server | `mcp.json` | Connects Cursor to the Pushary tools: `send_notification`, `ask_user`, `wait_for_answer`, `cancel_question`, `list_sessions` |
|
|
79
79
|
| Rule | `rules/pushary.mdc` | Always on guidance so the agent uses Pushary on its own |
|
|
80
80
|
| Skill | `skills/pushary/SKILL.md` | Full tool reference: parameters, examples, return values |
|
|
81
|
-
| Hook | `hooks/hooks.json` and `scripts/pushary-gate.mjs` |
|
|
81
|
+
| Hook | `hooks/hooks.json` and `scripts/pushary-gate.mjs` | Evaluates tool approvals and reports session/tool activity |
|
|
82
82
|
| Commands | `commands/` | `/pushary-test` and `/notify-when-done` |
|
|
83
83
|
|
|
84
84
|
## How the gate decides
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
The hooks register the supported execution boundaries; your dashboard policy decides which actions need approval. No shell-command regex hides a tool from policy evaluation. Lifecycle hooks report session starts, turn completion, tool results, compaction, and subagent activity.
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
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.
|
|
88
|
+
Timeout approvals apply only after the configured wait actually elapsed. Short editor hook budgets can end a wait earlier; cancellation is attempted before handoff. An uncertain cancellation denies the action. Local plugin installation does not install hooks into remote or cloud agent environments.
|
|
91
89
|
|
|
92
90
|
## Commands
|
|
93
91
|
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
"beforeShellExecution": [
|
|
5
5
|
{
|
|
6
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
7
|
"timeout": 60,
|
|
9
8
|
"failClosed": true
|
|
10
9
|
}
|
|
@@ -15,6 +14,62 @@
|
|
|
15
14
|
"timeout": 60,
|
|
16
15
|
"failClosed": true
|
|
17
16
|
}
|
|
17
|
+
],
|
|
18
|
+
"preToolUse": [
|
|
19
|
+
{
|
|
20
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
21
|
+
"matcher": "Write|Delete|Edit",
|
|
22
|
+
"timeout": 60,
|
|
23
|
+
"failClosed": true
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"postToolUse": [
|
|
27
|
+
{
|
|
28
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
29
|
+
"timeout": 10
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"stop": [
|
|
33
|
+
{
|
|
34
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
35
|
+
"timeout": 10
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"sessionStart": [
|
|
39
|
+
{
|
|
40
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
41
|
+
"timeout": 10
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"sessionEnd": [
|
|
45
|
+
{
|
|
46
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
47
|
+
"timeout": 10
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"beforeSubmitPrompt": [
|
|
51
|
+
{
|
|
52
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
53
|
+
"timeout": 10
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"preCompact": [
|
|
57
|
+
{
|
|
58
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
59
|
+
"timeout": 10
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"subagentStart": [
|
|
63
|
+
{
|
|
64
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
65
|
+
"timeout": 10
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"subagentStop": [
|
|
69
|
+
{
|
|
70
|
+
"command": "node ./scripts/pushary-gate.mjs",
|
|
71
|
+
"timeout": 10
|
|
72
|
+
}
|
|
18
73
|
]
|
|
19
74
|
}
|
|
20
75
|
}
|
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Pushary
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
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 : { "hook_event_name": string, "cwd": string, "conversation_id": string, ... }
|
|
20
|
-
// beforeShellExecution adds { "command": string }
|
|
21
|
-
// beforeMCPExecution adds { "tool_name", "tool_input", "mcp_server_name" }
|
|
22
|
-
// stdout : { "permission": "allow" | "deny" | "ask", "user_message"?, "agent_message"? }
|
|
23
|
-
//
|
|
24
|
-
// Failure model: every handled path writes a decision and exits 0. Network/parse
|
|
25
|
-
// errors and no-policy fall back to "ask" (Cursor's own prompt) — it never silently
|
|
26
|
-
// allows a risky command. A 55s hard guard guarantees a decision before the hook's
|
|
27
|
-
// `failClosed` deadline; only a catastrophic crash (e.g. Node missing) leaves no
|
|
28
|
-
// output, in which case `failClosed: true` blocks the command rather than allowing
|
|
29
|
-
// it unapproved.
|
|
30
|
-
|
|
31
|
-
import { createHash } from 'node:crypto'
|
|
2
|
+
// Pushary Cursor hooks: server policy, fenced phone approvals, and lifecycle telemetry.
|
|
3
|
+
// Dependency-free for marketplace installs. Regenerate agent-hooks/data after edits.
|
|
4
|
+
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
32
6
|
import { hostname } from 'node:os'
|
|
33
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
34
8
|
import { basename, dirname, join } from 'node:path'
|
|
35
9
|
import { fileURLToPath } from 'node:url'
|
|
36
10
|
|
|
37
|
-
const BASE_URL = 'https://pushary.com'
|
|
11
|
+
const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || 'https://pushary.com'
|
|
38
12
|
const MCP_URL = `${BASE_URL}/api/mcp/mcp`
|
|
39
13
|
const MAX_BLOCK_MS = 45_000 // longest we can wait before Cursor's hook timeout
|
|
40
14
|
const WAIT_CHUNK_MS = 20_000 // per wait_for_answer long-poll
|
|
@@ -45,9 +19,14 @@ const HARD_GUARD_MS = 55_000 // force a graceful "ask" before failClosed (60s) f
|
|
|
45
19
|
|
|
46
20
|
// ── Cursor decisions ──────────────────────────────────────────────────────────
|
|
47
21
|
const ALLOW = { permission: 'allow' }
|
|
48
|
-
|
|
22
|
+
let genericToolHook = false
|
|
23
|
+
const ask = (agentMessage) => genericToolHook ? deny(agentMessage ?? 'Approval requires a user decision. Ask through Pushary, then retry.') : (agentMessage ? { permission: 'ask', agent_message: agentMessage } : { permission: 'ask' })
|
|
49
24
|
const deny = (agentMessage) => ({ permission: 'deny', user_message: 'Command denied via Pushary.', agent_message: agentMessage })
|
|
25
|
+
const unresolved = (verdict) => genericToolHook && verdict?.reason === 'not_gated'
|
|
26
|
+
? ALLOW
|
|
27
|
+
: ask(genericToolHook ? 'Pushary could not reach a verdict for this change. Retry shortly.' : undefined)
|
|
50
28
|
|
|
29
|
+
let activeQuestion
|
|
51
30
|
let done = false
|
|
52
31
|
const respond = (decision) => {
|
|
53
32
|
if (done) return
|
|
@@ -56,10 +35,6 @@ const respond = (decision) => {
|
|
|
56
35
|
process.exit(0)
|
|
57
36
|
}
|
|
58
37
|
|
|
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
38
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
64
39
|
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi)
|
|
65
40
|
|
|
@@ -355,6 +330,7 @@ const askArgs = (request, project, ident) => ({
|
|
|
355
330
|
toolTarget: request.toolTarget,
|
|
356
331
|
actionBody: deriveActionBody(request.display),
|
|
357
332
|
wait: false,
|
|
333
|
+
waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
|
|
358
334
|
})
|
|
359
335
|
|
|
360
336
|
const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
|
|
@@ -362,14 +338,12 @@ const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
|
|
|
362
338
|
const remaining = clamp(deadlineMs - Date.now(), 1_000, WAIT_CHUNK_MS)
|
|
363
339
|
try {
|
|
364
340
|
const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: remaining })
|
|
365
|
-
if (answer?.
|
|
341
|
+
if (answer?.error) return STOPPED
|
|
342
|
+
if (answer?.answered || stopped(answer) || (answer?.status && answer.status !== 'pending')) return answer
|
|
366
343
|
} catch {
|
|
367
|
-
|
|
368
|
-
await sleep(POLL_GAP_MS)
|
|
369
|
-
continue
|
|
344
|
+
return { answered: false, handoffAction: 'stop' }
|
|
370
345
|
}
|
|
371
|
-
|
|
372
|
-
await sleep(POLL_GAP_MS)
|
|
346
|
+
await sleep(Math.min(POLL_GAP_MS, Math.max(0, deadlineMs - Date.now())))
|
|
373
347
|
}
|
|
374
348
|
return { answered: false }
|
|
375
349
|
}
|
|
@@ -377,25 +351,29 @@ const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
|
|
|
377
351
|
const fromTimeoutAction = (action, deniedReason) =>
|
|
378
352
|
action === 'approve' ? ALLOW : action === 'deny' ? deny(deniedReason) : ask()
|
|
379
353
|
|
|
380
|
-
const fromAnswer = (answer, deniedReason) => {
|
|
354
|
+
const fromAnswer = (answer, deniedReason = 'Denied via Pushary.') => {
|
|
355
|
+
activeQuestion = undefined
|
|
381
356
|
if (answer.value === 'defer') return ask()
|
|
382
357
|
return answer.value === 'yes' ? ALLOW : deny(deniedReason)
|
|
383
358
|
}
|
|
384
359
|
|
|
360
|
+
const stopped = (result) => result?.handoffAction === 'stop'
|
|
361
|
+
|| ['cancelled', 'unavailable', 'stopped', 'missing'].includes(result?.status)
|
|
362
|
+
const STOPPED = { answered: false, handoffAction: 'stop' }
|
|
363
|
+
const STOP_REASON = 'This approval was cancelled or its state could not be verified. Do not run the action; wait for a new user instruction.'
|
|
364
|
+
|
|
385
365
|
const withdraw = async (apiKey, correlationId) => {
|
|
386
|
-
const unanswered = { answered: false }
|
|
387
|
-
let cancelled
|
|
388
|
-
try {
|
|
389
|
-
cancelled = await callTool(apiKey, 'cancel_question', { correlationId }, WITHDRAW_TIMEOUT_MS)
|
|
390
|
-
} catch {
|
|
391
|
-
return unanswered
|
|
392
|
-
}
|
|
393
|
-
if (cancelled?.cancelled !== false || cancelled?.status === 'unavailable') return unanswered
|
|
394
366
|
try {
|
|
367
|
+
const cancelled = await callTool(apiKey, 'cancel_question', { correlationId }, WITHDRAW_TIMEOUT_MS)
|
|
368
|
+
if (stopped(cancelled)) return STOPPED
|
|
369
|
+
if (cancelled?.cancelled === true) return { answered: false }
|
|
395
370
|
const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: 1_000 }, WITHDRAW_TIMEOUT_MS)
|
|
396
|
-
|
|
371
|
+
if (answer?.answered) return answer
|
|
372
|
+
return ['expired', 'missing'].includes(answer?.status) ? { answered: false } : STOPPED
|
|
397
373
|
} catch {
|
|
398
|
-
return
|
|
374
|
+
return STOPPED
|
|
375
|
+
} finally {
|
|
376
|
+
activeQuestion = undefined
|
|
399
377
|
}
|
|
400
378
|
}
|
|
401
379
|
|
|
@@ -416,12 +394,21 @@ const denialFor = (noun) =>
|
|
|
416
394
|
* was not a shell execution fell out at the empty-command guard as `ask`. That
|
|
417
395
|
* was correct while `beforeShellExecution` was the only thing registered.
|
|
418
396
|
*
|
|
419
|
-
* Returns null
|
|
420
|
-
* Cursor's own prompt.
|
|
397
|
+
* Returns null for events and tools handled elsewhere.
|
|
421
398
|
*/
|
|
422
399
|
const describeRequest = (input) => {
|
|
423
400
|
const event = typeof input.hook_event_name === 'string' ? input.hook_event_name : ''
|
|
424
401
|
|
|
402
|
+
if (event === 'preToolUse') {
|
|
403
|
+
// Shell and MCP have their own gate events; never ask twice.
|
|
404
|
+
if (!['Write', 'Delete', 'Edit'].includes(input.tool_name)) return null
|
|
405
|
+
const params = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}
|
|
406
|
+
const display = `${input.tool_name}\n${JSON.stringify(params)}`
|
|
407
|
+
return { toolName: input.tool_name, toolInputs: [params], display,
|
|
408
|
+
toolTarget: String(params.file_path ?? params.path ?? '').slice(0, 120),
|
|
409
|
+
prompt: 'Allow this file change?', verb: 'change this file', denied: denialFor('file change') }
|
|
410
|
+
}
|
|
411
|
+
|
|
425
412
|
if (event === 'beforeMCPExecution') {
|
|
426
413
|
const tool = typeof input.tool_name === 'string' ? input.tool_name.trim() : ''
|
|
427
414
|
if (!tool) return null
|
|
@@ -473,64 +460,88 @@ const describeRequest = (input) => {
|
|
|
473
460
|
// push_only: wait up to the policy timeout, then apply the timeout action.
|
|
474
461
|
const handlePushOnly = async (apiKey, request, project, ident, timeoutSeconds, timeoutAction) => {
|
|
475
462
|
let asked
|
|
463
|
+
const args = { ...askArgs(request, project, ident), requestId: randomUUID() }
|
|
476
464
|
try {
|
|
477
|
-
asked = await withRetry(() => callTool(apiKey, 'ask_user',
|
|
465
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
|
|
478
466
|
} catch {
|
|
479
|
-
return
|
|
467
|
+
return deny('Pushary could not create a verifiable approval. Retry the action.')
|
|
480
468
|
}
|
|
481
|
-
if (
|
|
469
|
+
if (stopped(asked)) return deny(STOP_REASON)
|
|
470
|
+
if (asked?.answered) return fromAnswer(asked)
|
|
471
|
+
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
472
|
+
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
482
473
|
|
|
483
474
|
if (handedOff(asked)) {
|
|
484
475
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
476
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
485
477
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
486
478
|
return ask(handoffMessage(asked))
|
|
487
479
|
}
|
|
488
480
|
if (asked.noDevices) {
|
|
489
481
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
482
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
490
483
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
491
|
-
return
|
|
484
|
+
return ask('No device connected, approve here.')
|
|
492
485
|
}
|
|
493
486
|
|
|
494
487
|
const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
|
|
495
488
|
const cap = Math.min(realMs, MAX_BLOCK_MS)
|
|
496
|
-
const
|
|
489
|
+
const deadline = Date.now() + cap
|
|
490
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
|
|
491
|
+
if (stopped(answer)) {
|
|
492
|
+
await withdraw(apiKey, asked.correlationId)
|
|
493
|
+
return deny(STOP_REASON)
|
|
494
|
+
}
|
|
497
495
|
if (answer.answered) return fromAnswer(answer, request.denied)
|
|
498
496
|
|
|
499
497
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
498
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
500
499
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
501
500
|
|
|
502
501
|
// If Cursor's hook limit cut us off before the configured timeout, hand off to
|
|
503
502
|
// Cursor's own prompt rather than misapplying the policy's timeout action.
|
|
504
|
-
if (cap >= realMs) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
|
|
503
|
+
if (cap >= realMs && Date.now() >= deadline) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
|
|
505
504
|
return ask()
|
|
506
505
|
}
|
|
507
506
|
|
|
508
507
|
// push_first: race the push for a short window, then fall back to Cursor's prompt.
|
|
509
508
|
const handlePushFirst = async (apiKey, request, project, ident, pushFirstSeconds) => {
|
|
510
509
|
let asked
|
|
510
|
+
const args = { ...askArgs(request, project, ident), requestId: randomUUID() }
|
|
511
511
|
try {
|
|
512
|
-
asked = await withRetry(() => callTool(apiKey, 'ask_user',
|
|
512
|
+
asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
|
|
513
513
|
} catch {
|
|
514
|
-
return
|
|
514
|
+
return deny('Pushary could not create a verifiable approval. Retry the action.')
|
|
515
515
|
}
|
|
516
|
-
if (
|
|
516
|
+
if (stopped(asked)) return deny(STOP_REASON)
|
|
517
|
+
if (asked?.answered) return fromAnswer(asked)
|
|
518
|
+
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
519
|
+
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
517
520
|
|
|
518
521
|
if (handedOff(asked)) {
|
|
519
522
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
523
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
520
524
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
521
525
|
return ask(handoffMessage(asked))
|
|
522
526
|
}
|
|
523
527
|
if (asked.noDevices) {
|
|
524
528
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
529
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
525
530
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
526
531
|
return ask('No device connected, approve here.')
|
|
527
532
|
}
|
|
528
533
|
|
|
529
534
|
const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
|
|
530
|
-
const
|
|
535
|
+
const deadline = Date.now() + cap
|
|
536
|
+
const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
|
|
537
|
+
if (stopped(answer)) {
|
|
538
|
+
await withdraw(apiKey, asked.correlationId)
|
|
539
|
+
return deny(STOP_REASON)
|
|
540
|
+
}
|
|
531
541
|
if (answer.answered) return fromAnswer(answer, request.denied)
|
|
532
542
|
|
|
533
543
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
544
|
+
if (stopped(late)) return deny(STOP_REASON)
|
|
534
545
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
535
546
|
return ask('No answer from your phone in time, so the request was withdrawn there. Approve here.')
|
|
536
547
|
}
|
|
@@ -549,17 +560,56 @@ const handleNotifyOnly = async (apiKey, request, project, ident) => {
|
|
|
549
560
|
return ask()
|
|
550
561
|
}
|
|
551
562
|
|
|
563
|
+
const TELEMETRY_EVENTS = ["postToolUse", "stop", "sessionStart", "sessionEnd", "beforeSubmitPrompt", "preCompact", "subagentStart", "subagentStop"]
|
|
564
|
+
|
|
565
|
+
const reportTelemetry = async (input, source) => {
|
|
566
|
+
const apiKey = resolveApiKey()
|
|
567
|
+
if (!apiKey) return
|
|
568
|
+
const sentAt = new Date().toISOString()
|
|
569
|
+
const payload = JSON.stringify(input, (_key, value) => typeof value === 'string' ? redactSecretsDeep(value) : value)
|
|
570
|
+
if (Buffer.byteLength(payload) > 262144) return
|
|
571
|
+
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
572
|
+
const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id ?? ''
|
|
573
|
+
const hookId = hash([source, sessionId, input.hook_event_name, sentAt, hash(payload)].join('|')).slice(0, 32)
|
|
574
|
+
try {
|
|
575
|
+
await fetch(`${BASE_URL}/api/agent/hook`, {
|
|
576
|
+
method: 'POST',
|
|
577
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
578
|
+
body: JSON.stringify({ v: 1, machineId: getMachineId(), app: { platform: 'cli', version: 'editor-plugin' },
|
|
579
|
+
envelopes: [{ wire: 1, hookId, source, event: input.hook_event_name, sentAt,
|
|
580
|
+
cwd: input.cwd, repoKey: deriveRepoKey(input.cwd), payload }] }),
|
|
581
|
+
signal: AbortSignal.timeout(5000),
|
|
582
|
+
})
|
|
583
|
+
} catch { /* Telemetry never blocks an agent. */ }
|
|
584
|
+
}
|
|
585
|
+
|
|
552
586
|
const main = async () => {
|
|
587
|
+
// Backstop: if anything hangs, return "ask" rather than letting the hook time out
|
|
588
|
+
// (which, with failClosed, would block the command).
|
|
589
|
+
setTimeout(async () => {
|
|
590
|
+
if (!activeQuestion) return respond(deny('Pushary could not finish approval safely. Retry the action.'))
|
|
591
|
+
const late = await withdraw(activeQuestion.apiKey, activeQuestion.correlationId)
|
|
592
|
+
respond(late.answered ? fromAnswer(late) : deny('Pushary approval expired or could not be withdrawn safely. Retry the action.'))
|
|
593
|
+
}, HARD_GUARD_MS - 5_000).unref()
|
|
594
|
+
|
|
595
|
+
|
|
553
596
|
let input
|
|
554
597
|
try {
|
|
555
598
|
const raw = await readStdin()
|
|
556
599
|
input = raw.trim() ? JSON.parse(raw) : {}
|
|
557
600
|
} catch {
|
|
558
|
-
return respond(
|
|
601
|
+
return respond(deny('Pushary received invalid hook input. Retry the action.'))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
input.hook_event_name ??= input.hookEventName
|
|
605
|
+
if (TELEMETRY_EVENTS.includes(input.hook_event_name)) {
|
|
606
|
+
await reportTelemetry(input, 'cursor')
|
|
607
|
+
return respond({})
|
|
559
608
|
}
|
|
560
609
|
|
|
610
|
+
genericToolHook = input.hook_event_name === 'preToolUse'
|
|
561
611
|
const request = describeRequest(input)
|
|
562
|
-
if (!request) return respond(
|
|
612
|
+
if (!request) return respond({})
|
|
563
613
|
|
|
564
614
|
const apiKey = resolveApiKey()
|
|
565
615
|
if (!apiKey) {
|
|
@@ -575,9 +625,8 @@ const main = async () => {
|
|
|
575
625
|
try {
|
|
576
626
|
const verdict = await decide(apiKey, request, input.cwd, sessionId)
|
|
577
627
|
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
if (!verdict || verdict.kind === 'no_opinion') return respond(ask())
|
|
628
|
+
// Native shell/MCP prompts can take over; generic file hooks must deny ask.
|
|
629
|
+
if (!verdict || verdict.kind === 'no_opinion') return respond(unresolved(verdict))
|
|
581
630
|
if (verdict.kind === 'kill') return respond(deny(verdict.reason))
|
|
582
631
|
if (verdict.kind === 'allow') return respond(ALLOW)
|
|
583
632
|
if (verdict.kind === 'deny') return respond(deny(verdict.reason))
|
|
@@ -598,7 +647,7 @@ const main = async () => {
|
|
|
598
647
|
}
|
|
599
648
|
} catch (error) {
|
|
600
649
|
process.stderr.write(`[pushary-gate] ${error?.message ?? error}\n`)
|
|
601
|
-
return respond(
|
|
650
|
+
return respond(unresolved(null))
|
|
602
651
|
}
|
|
603
652
|
}
|
|
604
653
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pushary",
|
|
3
3
|
"description": "Push notifications, human-in-the-loop questions, and permission gating for your AI agent. Get a push when a task finishes, answer the agent from your phone, and approve risky commands before they run.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pushary",
|
|
7
7
|
"email": "business@pushary.com",
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Classify local reads, searches, diagnostics, and terminal/task output inspection as reads under the shared policy.
|
|
6
|
+
- Let known read-only tools proceed when no verdict is available; keep the native prompt for other tools.
|
|
7
|
+
|
|
3
8
|
## 0.2.0
|
|
4
9
|
|
|
5
10
|
The gate stopped carrying its own copy of the policy engine, and two defects went
|
|
@@ -18,9 +18,9 @@ There are three things.
|
|
|
18
18
|
|
|
19
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
20
|
|
|
21
|
-
2. Ask. The agent asks you questions through push: yes or no, multiple choice, or free text. It waits for your answer.
|
|
21
|
+
2. Ask. The agent asks you questions through push: yes or no, multiple choice, or free text. It waits for your answer. The bundled skill guides the agent to use Pushary for questions. Native VS Code question dialogs are not automatically intercepted.
|
|
22
22
|
|
|
23
|
-
3. Gate.
|
|
23
|
+
3. Gate. Every named tool reaches your Pushary policy before execution, including terminal commands, file edits, patches, and MCP calls. Standing rules are evaluated before safe-command handling. The policy can approve, deny, or ask; cancellation stops the action. Pushary’s own MCP tools are exempt so answering cannot deadlock.
|
|
24
24
|
|
|
25
25
|
## Requirements
|
|
26
26
|
|
|
@@ -66,7 +66,7 @@ Clone this repository, then add the absolute path to your VS Code `settings.json
|
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
The source plugin and CLI installer use the same decision service. The Mac app can install a native bridge that also carries queued messages back through supported hook responses.
|
|
70
70
|
|
|
71
71
|
## Set your API key
|
|
72
72
|
|
|
@@ -87,26 +87,18 @@ Install the Pushary app on your phone (or turn on web push) so the agent can rea
|
|
|
87
87
|
|------|------|--------------|
|
|
88
88
|
| MCP server | `.mcp.json` | Connects VS Code to the Pushary tools: `send_notification`, `ask_user`, `wait_for_answer`, `cancel_question` |
|
|
89
89
|
| Skill | `skills/pushary/SKILL.md` | Full tool reference: parameters, examples, return values, and the proactive-use guidance |
|
|
90
|
-
| Hook | `hooks/hooks.json` and `scripts/pushary-gate.mjs` |
|
|
90
|
+
| Hook | `hooks/hooks.json` and `scripts/pushary-gate.mjs` | Evaluates tool approvals and reports session/tool activity |
|
|
91
91
|
| Commands | `commands/` | `/pushary-test` and `/notify-when-done` |
|
|
92
92
|
|
|
93
93
|
## How the gate decides
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
VS Code currently ignores hook matchers, so the script sends every named tool to policy evaluation. Host aliases such as `run_in_terminal`, `create_file`, and `apply_patch` are normalized by the shared service; third-party `mcp_` IDs are retained because their server boundaries cannot safely be reconstructed from underscores.
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
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 behavior stays the same across agents.
|
|
100
|
-
|
|
101
|
-
### Why the filtering lives in the script
|
|
102
|
-
|
|
103
|
-
VS Code parses a hook's `matcher` but does not enforce it, so `PreToolUse` fires on every tool call the agent makes, including reads and searches. `hooks/hooks.json` therefore declares no matcher: one there would do nothing, and would only read as a promise the file cannot keep.
|
|
104
|
-
|
|
105
|
-
The one and only gate is `RISKY_COMMAND` in `scripts/pushary-gate.mjs`. To change which commands need approval, edit that regex. Everything else returns immediately without touching the disk or the network.
|
|
97
|
+
Lifecycle hooks report session starts, turn completion, tool results, compaction, and subagent activity. Timeout approval requires the configured wait to have elapsed; cancellation or unverifiable withdrawal denies the action. Stable and Insiders registrations are supported locally. Remote hosts and additional profiles require their own installation.
|
|
106
98
|
|
|
107
99
|
## Failure behavior
|
|
108
100
|
|
|
109
|
-
|
|
101
|
+
Known read-only tools proceed when a missing API key or unavailable verdict prevents policy evaluation. Other tools fall back to `ask`, which hands the decision to VS Code's own approval prompt. A 55 second guard guarantees output before the hook's 60 second timeout.
|
|
110
102
|
|
|
111
103
|
## Cross-tool compatibility
|
|
112
104
|
|