@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/data/cursor-plugin/.cursor-plugin/plugin.json +1 -1
  4. package/data/cursor-plugin/CHANGELOG.md +5 -0
  5. package/data/cursor-plugin/README.md +6 -8
  6. package/data/cursor-plugin/hooks/hooks.json +56 -1
  7. package/data/cursor-plugin/scripts/pushary-gate.mjs +120 -71
  8. package/data/vscode-plugin/.claude-plugin/plugin.json +1 -1
  9. package/data/vscode-plugin/CHANGELOG.md +5 -0
  10. package/data/vscode-plugin/README.md +7 -15
  11. package/data/vscode-plugin/hooks/hooks.json +49 -0
  12. package/data/vscode-plugin/scripts/pushary-gate.mjs +208 -88
  13. package/dist/bin/pushary-bell-hook.js +20 -22
  14. package/dist/bin/pushary-bell.js +9 -11
  15. package/dist/bin/pushary-claude.js +58 -46
  16. package/dist/bin/pushary-clean.js +21 -20
  17. package/dist/bin/pushary-codex-bridge.js +120 -25
  18. package/dist/bin/pushary-codex-hook.js +43 -63
  19. package/dist/bin/pushary-codex.js +11 -10
  20. package/dist/bin/pushary-connect.js +8 -10
  21. package/dist/bin/pushary-cowork.js +5 -5
  22. package/dist/bin/pushary-daemon.js +26 -28
  23. package/dist/bin/pushary-disconnect.js +11 -10
  24. package/dist/bin/pushary-doctor.js +25 -25
  25. package/dist/bin/pushary-elicitation-hook.js +30 -31
  26. package/dist/bin/pushary-gemini-bridge.js +9 -11
  27. package/dist/bin/pushary-gemini-hook.js +45 -79
  28. package/dist/bin/pushary-hook.js +27 -29
  29. package/dist/bin/pushary-login.js +57 -31
  30. package/dist/bin/pushary-logout.js +6 -8
  31. package/dist/bin/pushary-mode.js +5 -5
  32. package/dist/bin/pushary-notification-hook.js +22 -24
  33. package/dist/bin/pushary-opencode-hook.js +38 -74
  34. package/dist/bin/pushary-permission-denied-hook.js +25 -27
  35. package/dist/bin/pushary-permission-hook.js +25 -27
  36. package/dist/bin/pushary-post-hook.js +22 -24
  37. package/dist/bin/pushary-prompt-hook.js +22 -24
  38. package/dist/bin/pushary-session-end-hook.js +22 -24
  39. package/dist/bin/pushary-session-start-hook.js +25 -27
  40. package/dist/bin/pushary-setup.js +78 -55
  41. package/dist/bin/pushary-stats.js +4 -4
  42. package/dist/bin/pushary-status.js +10 -12
  43. package/dist/bin/pushary-stop-hook.js +22 -24
  44. package/dist/bin/pushary-stopfailure-hook.js +22 -24
  45. package/dist/bin/pushary-suggestions.js +2 -2
  46. package/dist/bin/pushary-transcript-register.js +18 -20
  47. package/dist/bin/pushary-transcripts.js +7 -9
  48. package/dist/bin/pushary-upgrade.js +17 -19
  49. package/dist/bin/pushary-wait.js +5 -5
  50. package/dist/bin/pushary.js +1 -1
  51. package/dist/{chunk-7LUFYRH4.js → chunk-2D7BAOMY.js} +7 -7
  52. package/dist/{chunk-3ZUTNTZA.js → chunk-2ZTL3PGN.js} +1 -1
  53. package/dist/{chunk-EPFDN2ZT.js → chunk-32RMQO5Z.js} +1 -1
  54. package/dist/{chunk-EZIEXHYM.js → chunk-3AJIK5AO.js} +5 -2
  55. package/dist/{chunk-IPHOUBTU.js → chunk-4PX4QK2Z.js} +1 -1
  56. package/dist/{chunk-UW4LWAWY.js → chunk-4RF52LSU.js} +8 -8
  57. package/dist/{chunk-UOTSWS5N.js → chunk-67YNQ3QA.js} +43 -98
  58. package/dist/{chunk-G4TL4YWH.js → chunk-6AEM5RCS.js} +1 -1
  59. package/dist/{chunk-G24SEVCI.js → chunk-6VANQN5Q.js} +3 -7
  60. package/dist/{chunk-CKOLDI47.js → chunk-BDI2MXTT.js} +2 -2
  61. package/dist/{chunk-YKLCWWEI.js → chunk-BMANRGJQ.js} +1 -1
  62. package/dist/{chunk-2XUPP4WM.js → chunk-CUEZMPLJ.js} +1 -1
  63. package/dist/{chunk-A4R6BZCK.js → chunk-D62EONG3.js} +1 -1
  64. package/dist/{chunk-ZDAT5KMU.js → chunk-D6PRC3YQ.js} +1 -1
  65. package/dist/{chunk-MOY5X6HJ.js → chunk-DLLAWCQB.js} +1 -1
  66. package/dist/{chunk-DSTLA2TC.js → chunk-GOPIMNVC.js} +1 -1
  67. package/dist/{chunk-CH6DTGQR.js → chunk-HBHEX3JQ.js} +3 -3
  68. package/dist/{chunk-DNVDKHQD.js → chunk-HCMM3BOW.js} +226 -58
  69. package/dist/{chunk-76XICHYT.js → chunk-HGUZOUB2.js} +4 -4
  70. package/dist/{chunk-E3NF5CL2.js → chunk-HQIVR7MM.js} +6 -3
  71. package/dist/{chunk-JDFB3YKG.js → chunk-I6GZX3FT.js} +3 -3
  72. package/dist/{chunk-3OXHYPZG.js → chunk-IM2A5H33.js} +4 -1
  73. package/dist/{chunk-V27AHID3.js → chunk-IVQMNLSA.js} +3 -14
  74. package/dist/{chunk-4LL6LG5X.js → chunk-KAVDAZ7A.js} +43 -1
  75. package/dist/chunk-KBITFQST.js +6 -0
  76. package/dist/{chunk-AMZJ3CKW.js → chunk-KI4PA7MQ.js} +1 -1
  77. package/dist/{chunk-IAEXPTXA.js → chunk-N4VNLISN.js} +2 -2
  78. package/dist/{chunk-VX3BWMC5.js → chunk-OEKFD5BC.js} +1 -1
  79. package/dist/{chunk-QF4RI5CD.js → chunk-OI3DJA7N.js} +1 -1
  80. package/dist/{chunk-WB2C7VPF.js → chunk-OW6AZDY2.js} +3 -3
  81. package/dist/{chunk-4XXUHWIK.js → chunk-QHWXUNMN.js} +1 -1
  82. package/dist/{chunk-M7WWEFG6.js → chunk-QQGNFHIK.js} +2 -2
  83. package/dist/{chunk-YJAQRKH5.js → chunk-RLXVW4AM.js} +1 -1
  84. package/dist/{chunk-GUVCYYDK.js → chunk-S5CMLR35.js} +94 -15
  85. package/dist/{chunk-EUAQ4YAN.js → chunk-UCS65VVR.js} +1 -1
  86. package/dist/{chunk-WM5XMCID.js → chunk-VOSGZ6PH.js} +4 -4
  87. package/dist/{chunk-QUE76J4H.js → chunk-VTO3TNIB.js} +1 -1
  88. package/dist/{chunk-6FAHPGCZ.js → chunk-WDKHZRAS.js} +183 -25
  89. package/dist/{chunk-5GLPYGKS.js → chunk-WRDLHR2F.js} +2 -2
  90. package/dist/{chunk-RVSEGFPD.js → chunk-XLOELLAS.js} +9 -4
  91. package/dist/chunk-XS33QBJA.js +112 -0
  92. package/dist/{chunk-FQUV5KTM.js → chunk-Z2UWFEJW.js} +8 -8
  93. package/dist/src/index.d.ts +8 -1
  94. package/dist/src/index.js +11 -12
  95. package/package.json +1 -1
  96. package/dist/chunk-BC3VCZ3E.js +0 -86
  97. package/dist/chunk-RER3FRTD.js +0 -265
  98. 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 Switch approval mode (push_only, push_first, terminal_only)
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.1",
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. When Pushary is connected, the agent sends its questions to your phone instead of waiting in the editor.
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. 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.
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
- However you install it, Pushary talks to the same server, so the plugin and the CLI give you the same setup.
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` | Sends risky commands to your phone for approval |
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
- There are two layers.
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
- 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.
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 gate — Cursor `beforeShellExecution` and `beforeMCPExecution` hooks.
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 : { "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
- const ask = (agentMessage) => (agentMessage ? { permission: 'ask', agent_message: agentMessage } : { permission: 'ask' })
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?.answered) return answer
341
+ if (answer?.error) return STOPPED
342
+ if (answer?.answered || stopped(answer) || (answer?.status && answer.status !== 'pending')) return answer
366
343
  } catch {
367
- if (Date.now() + POLL_GAP_MS >= deadlineMs) break
368
- await sleep(POLL_GAP_MS)
369
- continue
344
+ return { answered: false, handoffAction: 'stop' }
370
345
  }
371
- if (Date.now() + POLL_GAP_MS >= deadlineMs) break
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
- return answer?.answered ? answer : unanswered
371
+ if (answer?.answered) return answer
372
+ return ['expired', 'missing'].includes(answer?.status) ? { answered: false } : STOPPED
397
373
  } catch {
398
- return unanswered
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 when there is nothing to gate, which the caller answers with
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', askArgs(request, project, ident)), 3)
465
+ asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
478
466
  } catch {
479
- return fromTimeoutAction(timeoutAction, 'Push notification failed; denied per your Pushary policy.')
467
+ return deny('Pushary could not create a verifiable approval. Retry the action.')
480
468
  }
481
- if (!asked?.correlationId) return ask()
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 fromTimeoutAction(timeoutAction, 'No device connected to approve on; denied per your Pushary policy.')
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 answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
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', askArgs(request, project, ident)), 3)
512
+ asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
513
513
  } catch {
514
- return ask()
514
+ return deny('Pushary could not create a verifiable approval. Retry the action.')
515
515
  }
516
- if (!asked?.correlationId) return ask()
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 answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
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(ask())
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(ask())
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
- // No verdict, or one that says nothing: Cursor's own prompt decides, exactly
579
- // as if this gate were not installed. Never a forced denial on an outage.
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(ask())
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.1",
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. When Pushary is connected, the agent sends its questions to your phone instead of waiting in the chat panel.
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. Risky terminal 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 VS Code's own approval prompt, so nothing dangerous runs silently.
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
- However you install it, Pushary talks to the same server, so the plugin and the CLI give you the same setup.
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` | Sends risky commands to your phone for approval |
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
- There are two layers.
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
- 1. `RISKY_COMMAND` in `scripts/pushary-gate.mjs` decides which commands get checked at all. Everything else passes straight through with no disk or network access.
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
- Every path returns a decision. Network errors, a missing API key, and unparseable input all fall back to `ask`, which hands the decision to VS Code's own approval prompt rather than allowing the command unapproved. A 55 second guard guarantees output before the hook's 60 second timeout.
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