@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
@@ -6,6 +6,55 @@
6
6
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
7
7
  "timeout": 60
8
8
  }
9
+ ],
10
+ "PostToolUse": [
11
+ {
12
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
13
+ "timeout": 10,
14
+ "type": "command"
15
+ }
16
+ ],
17
+ "Stop": [
18
+ {
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
20
+ "timeout": 10,
21
+ "type": "command"
22
+ }
23
+ ],
24
+ "SessionStart": [
25
+ {
26
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
27
+ "timeout": 10,
28
+ "type": "command"
29
+ }
30
+ ],
31
+ "UserPromptSubmit": [
32
+ {
33
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
34
+ "timeout": 10,
35
+ "type": "command"
36
+ }
37
+ ],
38
+ "PreCompact": [
39
+ {
40
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
41
+ "timeout": 10,
42
+ "type": "command"
43
+ }
44
+ ],
45
+ "SubagentStart": [
46
+ {
47
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
48
+ "timeout": 10,
49
+ "type": "command"
50
+ }
51
+ ],
52
+ "SubagentStop": [
53
+ {
54
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pushary-gate.mjs\"",
55
+ "timeout": 10,
56
+ "type": "command"
57
+ }
9
58
  ]
10
59
  }
11
60
  }
@@ -1,45 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // Pushary gate — VS Code `PreToolUse` agent hook.
3
- //
4
- // Routes risky terminal commands through your Pushary permission policy before
5
- // they run. What HAPPENS to a matched command is decided by your dashboard
6
- // policy (the same policy the @pushary/agent-hooks CLI uses for Claude Code), so
7
- // behavior is consistent across agents.
8
- //
9
- // It honors, per tool ("Bash"): auto-approve, the four approval modes
10
- // (push_only / push_first / notify_only / terminal_only), the timeout action
11
- // (approve / deny / escalate), a live mode override, and the kill switch, all
12
- // scoped to the VS Code chat session. Policy is cached in the temp dir for 5
13
- // minutes with a stale-fallback, and requests retry.
14
- //
15
- // Self-contained: no dependencies, uses the global fetch (Node 18+).
16
- //
17
- // Contract (https://code.visualstudio.com/docs/agent-customization/hooks):
18
- // stdin : { "hook_event_name": "PreToolUse", "tool_name": string,
19
- // "tool_input": object, "cwd": string, "session_id": string, ... }
20
- // stdout : { "continue": true } (not our business)
21
- // { "hookSpecificOutput": { "hookEventName": "PreToolUse",
22
- // "permissionDecision": "allow" | "deny" | "ask",
23
- // "permissionDecisionReason"?: string } }
24
- //
25
- // `hookEventName` is redundant for VS Code but required by Claude Code, which
26
- // can load this same plugin directory. Emitting it keeps one script valid for
27
- // both.
28
- //
29
- // WHY THIS SCRIPT SELF-FILTERS: VS Code parses a hook's `matcher` but does not
30
- // enforce it, so PreToolUse fires on EVERY tool call: reads, searches, edits.
31
- // ../hooks/hooks.json therefore carries no matcher at all, because a matcher
32
- // there would be inert and would only read as a guarantee it cannot make.
33
- // RISKY_COMMAND below is the one and only gate, and the pass-through path must
34
- // stay allocation-light and do no I/O, because it runs before every single tool
35
- // the agent uses.
36
- //
37
- // Failure model: every handled path writes a decision and exits 0. Network and
38
- // parse errors fall back to "ask" (VS Code's own prompt), so it never silently
39
- // allows a risky command. A 55s hard guard guarantees output before the hook's
40
- // 60s timeout fires.
2
+ // Pushary VS Code hooks: server policy, fenced phone approvals, and lifecycle telemetry.
3
+ // Dependency-free for marketplace installs. Regenerate agent-hooks/data after edits.
41
4
 
42
- import { createHash } from 'node:crypto'
5
+ import { createHash, randomUUID } from 'node:crypto'
43
6
  import { homedir, hostname } from 'node:os'
44
7
  import { existsSync, readFileSync } from 'node:fs'
45
8
  import { basename, dirname, join } from 'node:path'
@@ -56,9 +19,6 @@ const HARD_GUARD_MS = 55_000 // force a graceful "ask" before the 60s hook timeo
56
19
 
57
20
  // Which commands are worth a phone approval. This is the only place the set is
58
21
  // defined; edit it here and nowhere else.
59
- const RISKY_COMMAND =
60
- /\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/
61
-
62
22
  // VS Code, Copilot CLI and Claude Code each name the terminal tool differently,
63
23
  // and the name has changed across VS Code releases. Matching a normalized form
64
24
  // of every spelling we have seen is what keeps the gate working after an upgrade
@@ -78,6 +38,16 @@ const TERMINAL_TOOLS = new Set([
78
38
 
79
39
  const normalizeToolName = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
80
40
 
41
+ const READ_ONLY_TOOLS = new Set([
42
+ 'readfile', 'viewimage', 'listdir', 'listdirectory', 'filesearch', 'findfiles', 'grepsearch', 'findtextinfiles',
43
+ 'semanticsearch', 'searchcodebase', 'searchworkspacesymbols', 'geterrors', 'getchangedfiles', 'readprojectstructure',
44
+ 'getnotebooksummary', 'readnotebookcelloutput', 'testsearch', 'findtestfiles', 'testfailure', 'getvscodeapi',
45
+ 'getterminaloutput', 'gettaskoutput', 'terminalselection', 'terminallastcommand',
46
+ ])
47
+
48
+ export const isReadOnlyTool = (toolName) =>
49
+ typeof toolName === 'string' && READ_ONLY_TOOLS.has(normalizeToolName(toolName.replace(/^copilot_/, '')))
50
+
81
51
  // ── VS Code decisions ─────────────────────────────────────────────────────────
82
52
  const PASS = { continue: true }
83
53
  const decision = (permissionDecision, permissionDecisionReason) => ({
@@ -90,7 +60,9 @@ const decision = (permissionDecision, permissionDecisionReason) => ({
90
60
  const ALLOW = decision('allow')
91
61
  const ask = (reason) => decision('ask', reason)
92
62
  const deny = (reason) => decision('deny', reason)
63
+ const unresolved = (toolName, reason) => (isReadOnlyTool(toolName) ? PASS : ask(reason))
93
64
 
65
+ let activeQuestion
94
66
  let done = false
95
67
  const respond = (result) => {
96
68
  if (done) return
@@ -252,16 +224,17 @@ const GATE_TIMEOUT_MS = 5000
252
224
  * apply repo-scoped rules, since an unknown repository is not evidence of a
253
225
  * match -- which is what this gate already got, by being filtered server-side.
254
226
  */
255
- const decide = async (apiKey, command, cwd, sessionId) => {
227
+ const decide = async (apiKey, command, cwd, sessionId, ident = {}) => {
256
228
  try {
257
229
  const response = await fetch(`${BASE_URL}/api/agent/gate`, {
258
230
  method: 'POST',
259
231
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
260
232
  body: JSON.stringify({
261
233
  v: 1,
262
- source: 'cursor',
263
- toolName: 'Bash',
264
- toolInputs: [{ command }],
234
+ source: 'vscode',
235
+ toolName: ident.toolName ?? 'Bash',
236
+ toolInputs: ident.toolInputs ?? [{ command }],
237
+ repoKey: deriveRepoKey(cwd),
265
238
  cwd,
266
239
  sessionId,
267
240
  }),
@@ -275,6 +248,91 @@ const decide = async (apiKey, command, cwd, sessionId) => {
275
248
  }
276
249
  }
277
250
 
251
+ const REPO_KEY_MAX_LENGTH = 200
252
+ const MAX_PARENT_WALK = 64
253
+
254
+ const normalizeRepoRemote = (remoteUrl) => {
255
+ const raw = (remoteUrl || '').trim()
256
+ if (!raw) return undefined
257
+ let hostAndPath
258
+ const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):(.+)$/.exec(raw)
259
+ if (scp) {
260
+ hostAndPath = `${scp[1]}/${scp[2]}`
261
+ } else {
262
+ const url = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(raw)
263
+ if (!url || /^file:/i.test(raw)) return undefined
264
+ hostAndPath = url[1]
265
+ }
266
+ const slash = hostAndPath.indexOf('/')
267
+ if (slash <= 0) return undefined
268
+ const at = hostAndPath.slice(0, slash).lastIndexOf('@')
269
+ // Credentials must never survive: a remote can carry a token and this value is
270
+ // sent to the server and persisted.
271
+ const host = (at === -1 ? hostAndPath.slice(0, slash) : hostAndPath.slice(at + 1, slash)).replace(/:\d+$/, '')
272
+ const path = hostAndPath.slice(slash + 1).split('/').filter(Boolean).join('/')
273
+ if (!host || !path) return undefined
274
+ return `${host}/${path}`.replace(/\.git$/i, '').toLowerCase().slice(0, REPO_KEY_MAX_LENGTH)
275
+ }
276
+
277
+ const findGitDir = (startDir) => {
278
+ let current = startDir
279
+ for (let depth = 0; depth < MAX_PARENT_WALK; depth += 1) {
280
+ const candidate = join(current, '.git')
281
+ try {
282
+ if (existsSync(candidate)) {
283
+ const pointer = readFileSync(candidate, 'utf-8').trim()
284
+ // A directory read throws EISDIR, which is the ordinary-clone case.
285
+ if (pointer.startsWith('gitdir:')) {
286
+ const target = pointer.slice('gitdir:'.length).trim()
287
+ return target.startsWith('/') ? target : join(current, target)
288
+ }
289
+ }
290
+ } catch (error) {
291
+ if (error?.code === 'EISDIR') return candidate
292
+ }
293
+ const parent = dirname(current)
294
+ if (parent === current) return undefined
295
+ current = parent
296
+ }
297
+ return undefined
298
+ }
299
+
300
+ const deriveRepoKey = (cwd) => {
301
+ const override = process.env.PUSHARY_REPO_KEY
302
+ if (typeof override === 'string') {
303
+ const trimmed = override.trim()
304
+ if (trimmed.toLowerCase() === 'off') return undefined
305
+ if (trimmed) return trimmed.toLowerCase()
306
+ }
307
+ const dir = cwd || process.cwd()
308
+ try {
309
+ const gitDir = findGitDir(dir)
310
+ if (gitDir) {
311
+ // A worktree's config lives in the main git directory.
312
+ const wt = gitDir.replace(/\\/g, '/').indexOf('/worktrees/')
313
+ const configPath = wt === -1 ? join(gitDir, 'config') : join(gitDir.slice(0, wt), 'config')
314
+ if (existsSync(configPath)) {
315
+ let inOrigin = false
316
+ for (const rawLine of readFileSync(configPath, 'utf-8').split('\n')) {
317
+ const line = rawLine.trim()
318
+ if (line.startsWith('[')) { inOrigin = /^\[remote "origin"\]/.test(line); continue }
319
+ if (!inOrigin) continue
320
+ const url = /^url\s*=\s*(.+)$/.exec(line)
321
+ if (url) {
322
+ const normalized = normalizeRepoRemote(url[1].trim())
323
+ if (normalized) return normalized
324
+ }
325
+ }
326
+ }
327
+ const root = gitDir.endsWith('.git') ? dirname(gitDir) : dir
328
+ return `local/${basename(root).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
329
+ }
330
+ return `local/${basename(dir).toLowerCase()}`.slice(0, REPO_KEY_MAX_LENGTH)
331
+ } catch {
332
+ return undefined
333
+ }
334
+ }
335
+
278
336
  // ── action body capture + redaction (inlined mirror of describe.ts, since this
279
337
  // dependency-free hook cannot import the workspace) ───────────────────────────
280
338
  const ACTION_BODY_MAX = 4000
@@ -324,13 +382,15 @@ export const extractCommand = (toolInput) => {
324
382
  return ''
325
383
  }
326
384
 
327
- // The single decision that keeps this hook cheap: does this tool call need the
328
- // network at all? Exported so the test suite can assert the fast path directly.
385
+ // Every named tool reaches policy, as it does through the native bridge. A local
386
+ // allowlist would silently bypass new tools and user-authored standing denials.
329
387
  export const shouldGate = (toolName, toolInput) => {
330
- if (typeof toolName !== 'string' || !TERMINAL_TOOLS.has(normalizeToolName(toolName))) return null
331
- const command = extractCommand(toolInput)
332
- if (!command || !RISKY_COMMAND.test(command)) return null
333
- return command
388
+ if (typeof toolName !== 'string' || !toolName.trim() || toolName.startsWith('mcp__pushary__') || toolName.startsWith('mcp_pushary_')) return null
389
+ if (TERMINAL_TOOLS.has(normalizeToolName(toolName))) {
390
+ const command = extractCommand(toolInput)
391
+ if (command) return command
392
+ }
393
+ return `${toolName}: ${JSON.stringify(toolInput ?? {})}`
334
394
  }
335
395
 
336
396
  // ── network diagnosis ────────────────────────────────────────────────────────
@@ -365,15 +425,16 @@ export const describeNetworkFailure = (error, env = process.env) => {
365
425
  // this field too, but a credential should never travel to be scrubbed on
366
426
  // arrival, and the notify body below was scrubbed nowhere at all.
367
427
  const askArgs = (command, project, ident) => ({
368
- question: `Allow this command?\n\n${redactSecrets(command)}`,
428
+ question: `Allow this action?\n\n${redactSecrets(command)}`,
369
429
  type: 'confirm',
370
430
  context: `VS Code agent wants to run this in ${project}`,
371
431
  agentName: ident.agentName,
372
432
  sessionId: ident.sessionId,
373
433
  machineId: ident.machineId,
374
- toolName: 'Bash',
434
+ toolName: ident.toolName ?? 'Bash',
375
435
  actionBody: deriveActionBody(command),
376
436
  wait: false,
437
+ waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
377
438
  })
378
439
 
379
440
  const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
@@ -381,14 +442,12 @@ const pollForAnswer = async (apiKey, correlationId, deadlineMs) => {
381
442
  const remaining = clamp(deadlineMs - Date.now(), 1_000, WAIT_CHUNK_MS)
382
443
  try {
383
444
  const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: remaining })
384
- if (answer?.answered) return answer
445
+ if (answer?.error) return STOPPED
446
+ if (answer?.answered || stopped(answer) || (answer?.status && answer.status !== 'pending')) return answer
385
447
  } catch {
386
- if (Date.now() + POLL_GAP_MS >= deadlineMs) break
387
- await sleep(POLL_GAP_MS)
388
- continue
448
+ return { answered: false, handoffAction: 'stop' }
389
449
  }
390
- if (Date.now() + POLL_GAP_MS >= deadlineMs) break
391
- await sleep(POLL_GAP_MS)
450
+ await sleep(Math.min(POLL_GAP_MS, Math.max(0, deadlineMs - Date.now())))
392
451
  }
393
452
  return { answered: false }
394
453
  }
@@ -399,24 +458,28 @@ const fromTimeoutAction = (action, deniedReason) =>
399
458
  const DENIED = 'The user denied this command via a Pushary push approval. Do not run it. Propose an alternative or ask how to proceed.'
400
459
 
401
460
  const fromAnswer = (answer) => {
461
+ activeQuestion = undefined
402
462
  if (answer.value === 'defer') return ask()
403
463
  return answer.value === 'yes' ? ALLOW : deny(DENIED)
404
464
  }
405
465
 
466
+ const stopped = (result) => result?.handoffAction === 'stop'
467
+ || ['cancelled', 'unavailable', 'stopped', 'missing'].includes(result?.status)
468
+ const STOPPED = { answered: false, handoffAction: 'stop' }
469
+ 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.'
470
+
406
471
  const withdrawQuestion = async (apiKey, correlationId) => {
407
- const unanswered = { answered: false }
408
- let cancelled
409
- try {
410
- cancelled = await callTool(apiKey, 'cancel_question', { correlationId }, WITHDRAW_TIMEOUT_MS)
411
- } catch {
412
- return unanswered
413
- }
414
- if (cancelled?.cancelled !== false || cancelled?.status === 'unavailable') return unanswered
415
472
  try {
473
+ const cancelled = await callTool(apiKey, 'cancel_question', { correlationId }, WITHDRAW_TIMEOUT_MS)
474
+ if (stopped(cancelled)) return STOPPED
475
+ if (cancelled?.cancelled === true) return { answered: false }
416
476
  const answer = await callTool(apiKey, 'wait_for_answer', { correlationId, timeoutMs: 1_000 }, WITHDRAW_TIMEOUT_MS)
417
- return answer?.answered ? answer : unanswered
477
+ if (answer?.answered) return answer
478
+ return ['expired', 'missing'].includes(answer?.status) ? { answered: false } : STOPPED
418
479
  } catch {
419
- return unanswered
480
+ return STOPPED
481
+ } finally {
482
+ activeQuestion = undefined
420
483
  }
421
484
  }
422
485
 
@@ -428,66 +491,90 @@ const handoffMessage = (asked) =>
428
491
  // push_only: wait up to the policy timeout, then apply the timeout action.
429
492
  const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, timeoutAction) => {
430
493
  let asked
494
+ const args = { ...askArgs(command, project, ident), requestId: randomUUID() }
431
495
  try {
432
- asked = await withRetry(() => callTool(apiKey, 'ask_user', askArgs(command, project, ident)), 3)
496
+ asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
433
497
  } catch {
434
- return fromTimeoutAction(timeoutAction, 'Push notification failed; denied per your Pushary policy.')
498
+ return deny('Pushary could not create a verifiable approval. Retry the action.')
435
499
  }
436
- if (!asked?.correlationId) return ask()
500
+ if (stopped(asked)) return deny(STOP_REASON)
501
+ if (asked?.answered) return fromAnswer(asked)
502
+ if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
503
+ activeQuestion = { apiKey, correlationId: asked.correlationId }
437
504
 
438
505
  // Keyboard bypass: the user is at the keyboard, so VS Code's own prompt is the
439
506
  // faster channel.
440
507
  if (handedOff(asked)) {
441
508
  const late = await withdrawQuestion(apiKey, asked.correlationId)
509
+ if (stopped(late)) return deny(STOP_REASON)
442
510
  if (late.answered) return fromAnswer(late)
443
511
  return ask(handoffMessage(asked))
444
512
  }
445
513
  if (asked.noDevices) {
446
514
  const late = await withdrawQuestion(apiKey, asked.correlationId)
515
+ if (stopped(late)) return deny(STOP_REASON)
447
516
  if (late.answered) return fromAnswer(late)
448
- return fromTimeoutAction(timeoutAction, 'No device connected to approve on; denied per your Pushary policy.')
517
+ return ask('No device connected, approve here.')
449
518
  }
450
519
 
451
520
  const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
452
521
  const cap = Math.min(realMs, MAX_BLOCK_MS)
453
- const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
522
+ const deadline = Date.now() + cap
523
+ const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
524
+ if (stopped(answer)) {
525
+ await withdrawQuestion(apiKey, asked.correlationId)
526
+ return deny(STOP_REASON)
527
+ }
454
528
  if (answer.answered) return fromAnswer(answer)
455
529
 
456
530
  const late = await withdrawQuestion(apiKey, asked.correlationId)
531
+ if (stopped(late)) return deny(STOP_REASON)
457
532
  if (late.answered) return fromAnswer(late)
458
533
 
459
534
  // If VS Code's hook limit cut us off before the configured timeout, hand off to
460
535
  // VS Code's own prompt rather than misapplying the policy's timeout action.
461
- if (cap >= realMs) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
536
+ if (cap >= realMs && Date.now() >= deadline) return fromTimeoutAction(timeoutAction, 'No response within the approval timeout; denied per your Pushary policy.')
462
537
  return ask()
463
538
  }
464
539
 
465
540
  // push_first: race the push for a short window, then fall back to VS Code's prompt.
466
541
  const handlePushFirst = async (apiKey, command, project, ident, pushFirstSeconds) => {
467
542
  let asked
543
+ const args = { ...askArgs(command, project, ident), requestId: randomUUID() }
468
544
  try {
469
- asked = await withRetry(() => callTool(apiKey, 'ask_user', askArgs(command, project, ident)), 3)
545
+ asked = await withRetry(() => callTool(apiKey, 'ask_user', args), 3)
470
546
  } catch {
471
- return ask()
547
+ return deny('Pushary could not create a verifiable approval. Retry the action.')
472
548
  }
473
- if (!asked?.correlationId) return ask()
549
+ if (stopped(asked)) return deny(STOP_REASON)
550
+ if (asked?.answered) return fromAnswer(asked)
551
+ if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
552
+ activeQuestion = { apiKey, correlationId: asked.correlationId }
474
553
 
475
554
  if (handedOff(asked)) {
476
555
  const late = await withdrawQuestion(apiKey, asked.correlationId)
556
+ if (stopped(late)) return deny(STOP_REASON)
477
557
  if (late.answered) return fromAnswer(late)
478
558
  return ask(handoffMessage(asked))
479
559
  }
480
560
  if (asked.noDevices) {
481
561
  const late = await withdrawQuestion(apiKey, asked.correlationId)
562
+ if (stopped(late)) return deny(STOP_REASON)
482
563
  if (late.answered) return fromAnswer(late)
483
564
  return ask('No device connected, approve here.')
484
565
  }
485
566
 
486
567
  const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
487
- const answer = await pollForAnswer(apiKey, asked.correlationId, Date.now() + cap)
568
+ const deadline = Date.now() + cap
569
+ const answer = await pollForAnswer(apiKey, asked.correlationId, deadline)
570
+ if (stopped(answer)) {
571
+ await withdrawQuestion(apiKey, asked.correlationId)
572
+ return deny(STOP_REASON)
573
+ }
488
574
  if (answer.answered) return fromAnswer(answer)
489
575
 
490
576
  const late = await withdrawQuestion(apiKey, asked.correlationId)
577
+ if (stopped(late)) return deny(STOP_REASON)
491
578
  if (late.answered) return fromAnswer(late)
492
579
  return ask('No answer from your phone in time, so the request was withdrawn there. Approve here.')
493
580
  }
@@ -508,11 +595,38 @@ const handleNotifyOnly = async (apiKey, command, project, ident) => {
508
595
  return ask()
509
596
  }
510
597
 
598
+ const TELEMETRY_EVENTS = ["PostToolUse", "Stop", "SessionStart", "UserPromptSubmit", "PreCompact", "SubagentStart", "SubagentStop"]
599
+
600
+ const reportTelemetry = async (input, source) => {
601
+ const apiKey = resolveApiKey()
602
+ if (!apiKey) return
603
+ const sentAt = new Date().toISOString()
604
+ const payload = JSON.stringify(input, (_key, value) => typeof value === 'string' ? redactSecretsDeep(value) : value)
605
+ if (Buffer.byteLength(payload) > 262144) return
606
+ const hash = value => createHash('sha256').update(value).digest('hex')
607
+ const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id ?? ''
608
+ const hookId = hash([source, sessionId, input.hook_event_name, sentAt, hash(payload)].join('|')).slice(0, 32)
609
+ try {
610
+ await fetch(`${BASE_URL}/api/agent/hook`, {
611
+ method: 'POST',
612
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
613
+ body: JSON.stringify({ v: 1, machineId: getMachineId(), app: { platform: 'cli', version: 'editor-plugin' },
614
+ envelopes: [{ wire: 1, hookId, source, event: input.hook_event_name, sentAt,
615
+ cwd: input.cwd, repoKey: deriveRepoKey(input.cwd), payload }] }),
616
+ signal: AbortSignal.timeout(5000),
617
+ })
618
+ } catch { /* Telemetry never blocks an agent. */ }
619
+ }
620
+
511
621
  const main = async () => {
512
622
  // Backstop: if anything hangs, return "ask" rather than letting the hook time
513
623
  // out and leave the agent with no decision at all. Scheduled here rather than
514
624
  // at module scope so importing this file for its helpers arms nothing.
515
- setTimeout(() => respond(ask()), HARD_GUARD_MS).unref()
625
+ setTimeout(async () => {
626
+ if (!activeQuestion) return respond(deny('Pushary could not finish approval safely. Retry the action.'))
627
+ const late = await withdrawQuestion(activeQuestion.apiKey, activeQuestion.correlationId)
628
+ respond(late.answered ? fromAnswer(late) : deny('Pushary approval expired or could not be withdrawn safely. Retry the action.'))
629
+ }, HARD_GUARD_MS - 5_000).unref()
516
630
 
517
631
  let input
518
632
  try {
@@ -530,6 +644,12 @@ const main = async () => {
530
644
  return respond(PASS)
531
645
  }
532
646
 
647
+ input.hook_event_name ??= input.hookEventName
648
+ if (TELEMETRY_EVENTS.includes(input.hook_event_name)) {
649
+ await reportTelemetry(input, 'vscode')
650
+ return respond({})
651
+ }
652
+
533
653
  // Fast path. This runs before every tool the agent uses, so it must stay free
534
654
  // of disk and network work.
535
655
  const command = shouldGate(input.tool_name, input.tool_input)
@@ -539,20 +659,20 @@ const main = async () => {
539
659
  if (!apiKey) {
540
660
  diag('no API key found (PUSHARY_API_KEY, the plugin .mcp.json, or ~/.pushary/config.json). Run: npx @pushary/agent-hooks setup')
541
661
  return respond(
542
- ask('Pushary is not configured: run `npx @pushary/agent-hooks setup` (get a key at https://pushary.com) to route this approval to your phone.')
662
+ unresolved(input.tool_name, 'Pushary is not configured: run `npx @pushary/agent-hooks setup` (get a key at https://pushary.com) to route this approval to your phone.')
543
663
  )
544
664
  }
545
665
 
546
666
  const project = basename(input.cwd || process.cwd()) || 'workspace'
547
- const sessionId = typeof input.session_id === 'string' ? input.session_id : undefined
548
- const ident = { agentName: `VS Code - ${project}`, sessionId, machineId: getMachineId() }
667
+ const sessionId = typeof (input.session_id ?? input.sessionId) === 'string' ? (input.session_id ?? input.sessionId) : undefined
668
+ const ident = { agentName: `VS Code - ${project}`, sessionId, machineId: getMachineId(), toolName: input.tool_name, toolInputs: [input.tool_input ?? {}] }
549
669
 
550
670
  try {
551
- const verdict = await decide(apiKey, command, input.cwd, sessionId)
671
+ const verdict = await decide(apiKey, command, input.cwd, sessionId, ident)
552
672
 
553
673
  // No verdict, or one that says nothing: VS Code's own prompt decides, exactly
554
674
  // as if this gate were not installed. Never a forced denial on an outage.
555
- if (!verdict || verdict.kind === 'no_opinion') return respond(ask())
675
+ if (!verdict || verdict.kind === 'no_opinion') return respond(unresolved(input.tool_name))
556
676
  if (verdict.kind === 'kill') return respond(deny(verdict.reason))
557
677
  if (verdict.kind === 'allow') return respond(ALLOW)
558
678
  if (verdict.kind === 'deny') return respond(deny(verdict.reason))
@@ -573,7 +693,7 @@ const main = async () => {
573
693
  }
574
694
  } catch (error) {
575
695
  diag(describeNetworkFailure(error))
576
- return respond(ask())
696
+ return respond(unresolved(input.tool_name))
577
697
  }
578
698
  }
579
699
 
@@ -1,38 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleBell
4
- } from "../chunk-QUE76J4H.js";
4
+ } from "../chunk-VTO3TNIB.js";
5
5
  import {
6
6
  readHookInput
7
- } from "../chunk-VX3BWMC5.js";
8
- import "../chunk-UW4LWAWY.js";
9
- import "../chunk-YKLCWWEI.js";
10
- import "../chunk-WM5XMCID.js";
11
- import "../chunk-3ZUTNTZA.js";
12
- import "../chunk-2XUPP4WM.js";
13
- import "../chunk-CH6DTGQR.js";
14
- import "../chunk-RVSEGFPD.js";
15
- import "../chunk-7LUFYRH4.js";
16
- import "../chunk-DSTLA2TC.js";
17
- import "../chunk-EZIEXHYM.js";
18
- import "../chunk-IPHOUBTU.js";
7
+ } from "../chunk-OEKFD5BC.js";
8
+ import "../chunk-4RF52LSU.js";
9
+ import "../chunk-BMANRGJQ.js";
10
+ import "../chunk-VOSGZ6PH.js";
11
+ import "../chunk-2ZTL3PGN.js";
12
+ import "../chunk-CUEZMPLJ.js";
13
+ import "../chunk-HBHEX3JQ.js";
14
+ import "../chunk-XLOELLAS.js";
15
+ import "../chunk-2D7BAOMY.js";
16
+ import "../chunk-GOPIMNVC.js";
17
+ import "../chunk-3AJIK5AO.js";
18
+ import "../chunk-4PX4QK2Z.js";
19
19
  import "../chunk-EQRS3SGM.js";
20
- import "../chunk-QF4RI5CD.js";
20
+ import "../chunk-OI3DJA7N.js";
21
21
  import "../chunk-XHKBHWLX.js";
22
22
  import "../chunk-L4ZCFMWW.js";
23
- import "../chunk-V27AHID3.js";
24
- import "../chunk-IAEXPTXA.js";
25
- import "../chunk-WB2C7VPF.js";
23
+ import "../chunk-IVQMNLSA.js";
24
+ import "../chunk-N4VNLISN.js";
25
+ import "../chunk-OW6AZDY2.js";
26
26
  import "../chunk-GMXKITVA.js";
27
27
  import "../chunk-DINDL2CF.js";
28
- import "../chunk-GUVCYYDK.js";
29
- import "../chunk-BC3VCZ3E.js";
28
+ import "../chunk-S5CMLR35.js";
30
29
  import "../chunk-7QLSKOSU.js";
31
30
  import "../chunk-KZERVKTD.js";
32
- import "../chunk-X7YWD6IS.js";
33
- import "../chunk-4LL6LG5X.js";
31
+ import "../chunk-KAVDAZ7A.js";
34
32
  import "../chunk-2UMNXADU.js";
35
- import "../chunk-6FAHPGCZ.js";
33
+ import "../chunk-WDKHZRAS.js";
36
34
 
37
35
  // bin/pushary-bell-hook.ts
38
36
  var main = async () => {
@@ -5,41 +5,39 @@ import {
5
5
  liveAgentCount,
6
6
  ring,
7
7
  upgradeThreshold
8
- } from "../chunk-QUE76J4H.js";
8
+ } from "../chunk-VTO3TNIB.js";
9
9
  import {
10
10
  addBellHooks,
11
11
  bellInstalled,
12
12
  removeBellHooks
13
- } from "../chunk-DSTLA2TC.js";
14
- import "../chunk-EZIEXHYM.js";
13
+ } from "../chunk-GOPIMNVC.js";
14
+ import "../chunk-3AJIK5AO.js";
15
15
  import "../chunk-L4ZCFMWW.js";
16
16
  import {
17
17
  claudeSettings
18
- } from "../chunk-WB2C7VPF.js";
18
+ } from "../chunk-OW6AZDY2.js";
19
19
  import {
20
20
  createIo
21
21
  } from "../chunk-5RUIFDTP.js";
22
22
  import {
23
23
  exitOnHelpFlag
24
- } from "../chunk-MOY5X6HJ.js";
25
- import "../chunk-ZDAT5KMU.js";
24
+ } from "../chunk-DLLAWCQB.js";
25
+ import "../chunk-D6PRC3YQ.js";
26
26
  import {
27
27
  parseFlags
28
28
  } from "../chunk-GMXKITVA.js";
29
29
  import "../chunk-DINDL2CF.js";
30
- import "../chunk-GUVCYYDK.js";
31
- import "../chunk-BC3VCZ3E.js";
30
+ import "../chunk-S5CMLR35.js";
32
31
  import "../chunk-7QLSKOSU.js";
33
32
  import {
34
33
  EXIT
35
34
  } from "../chunk-KZERVKTD.js";
36
- import "../chunk-X7YWD6IS.js";
37
35
  import {
38
36
  readJsonSafe,
39
37
  writeJsonAtomic
40
- } from "../chunk-4LL6LG5X.js";
38
+ } from "../chunk-KAVDAZ7A.js";
41
39
  import "../chunk-2UMNXADU.js";
42
- import "../chunk-6FAHPGCZ.js";
40
+ import "../chunk-WDKHZRAS.js";
43
41
 
44
42
  // bin/pushary-bell.ts
45
43
  exitOnHelpFlag("bell");