@pushary/agent-hooks 0.95.5 → 0.95.7

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 (88) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/data/cursor-plugin/.cursor-plugin/plugin.json +1 -1
  3. package/data/cursor-plugin/CHANGELOG.md +7 -0
  4. package/data/cursor-plugin/scripts/pushary-gate.mjs +72 -17
  5. package/data/vscode-plugin/.claude-plugin/plugin.json +1 -1
  6. package/data/vscode-plugin/CHANGELOG.md +6 -0
  7. package/data/vscode-plugin/scripts/pushary-gate.mjs +76 -24
  8. package/dist/bin/pushary-bell-hook.js +18 -18
  9. package/dist/bin/pushary-bell.js +7 -7
  10. package/dist/bin/pushary-claude.js +24 -24
  11. package/dist/bin/pushary-clean.js +18 -18
  12. package/dist/bin/pushary-codex-bridge.js +29 -12
  13. package/dist/bin/pushary-codex-hook.js +29 -29
  14. package/dist/bin/pushary-codex.js +7 -7
  15. package/dist/bin/pushary-connect.js +8 -8
  16. package/dist/bin/pushary-cowork.js +5 -5
  17. package/dist/bin/pushary-daemon.js +24 -24
  18. package/dist/bin/pushary-disconnect.js +8 -8
  19. package/dist/bin/pushary-doctor.js +91 -36
  20. package/dist/bin/pushary-elicitation-hook.js +17 -17
  21. package/dist/bin/pushary-gemini-bridge.js +9 -9
  22. package/dist/bin/pushary-gemini-hook.js +65 -50
  23. package/dist/bin/pushary-hook.js +24 -24
  24. package/dist/bin/pushary-login.js +9 -9
  25. package/dist/bin/pushary-logout.js +6 -6
  26. package/dist/bin/pushary-mode.js +5 -5
  27. package/dist/bin/pushary-notification-hook.js +19 -19
  28. package/dist/bin/pushary-opencode-hook.js +29 -29
  29. package/dist/bin/pushary-permission-denied-hook.js +22 -22
  30. package/dist/bin/pushary-permission-hook.js +22 -22
  31. package/dist/bin/pushary-post-hook.js +19 -19
  32. package/dist/bin/pushary-prompt-hook.js +19 -19
  33. package/dist/bin/pushary-session-end-hook.js +19 -19
  34. package/dist/bin/pushary-session-start-hook.js +22 -22
  35. package/dist/bin/pushary-setup.js +45 -38
  36. package/dist/bin/pushary-stats.js +4 -4
  37. package/dist/bin/pushary-status.js +14 -10
  38. package/dist/bin/pushary-stop-hook.js +19 -19
  39. package/dist/bin/pushary-stopfailure-hook.js +19 -19
  40. package/dist/bin/pushary-suggestions.js +2 -2
  41. package/dist/bin/pushary-transcript-register.js +16 -16
  42. package/dist/bin/pushary-transcripts.js +7 -7
  43. package/dist/bin/pushary-upgrade.js +16 -16
  44. package/dist/bin/pushary-wait.js +8 -7
  45. package/dist/bin/pushary.js +1 -1
  46. package/dist/{chunk-N4VNLISN.js → chunk-4OSQZ5TB.js} +2 -2
  47. package/dist/{chunk-IM2A5H33.js → chunk-54W7ELDQ.js} +1 -1
  48. package/dist/{chunk-UCS65VVR.js → chunk-5GKNAQTG.js} +1 -1
  49. package/dist/{chunk-Z2UWFEJW.js → chunk-6W2WGNO5.js} +7 -7
  50. package/dist/{chunk-2D7BAOMY.js → chunk-7RNY3UHJ.js} +3 -3
  51. package/dist/{chunk-OW6AZDY2.js → chunk-BGX4QUQF.js} +1 -1
  52. package/dist/{chunk-I6GZX3FT.js → chunk-BQL7UDIB.js} +1 -1
  53. package/dist/{chunk-HGUZOUB2.js → chunk-CNUDZS4V.js} +3 -3
  54. package/dist/chunk-CVWG2N3Y.js +16 -0
  55. package/dist/{chunk-BMANRGJQ.js → chunk-EMGGUO2Q.js} +1 -1
  56. package/dist/{chunk-QHWXUNMN.js → chunk-FRXZMCVA.js} +1 -1
  57. package/dist/{chunk-DLLAWCQB.js → chunk-GOQHN2UH.js} +1 -1
  58. package/dist/{chunk-HBHEX3JQ.js → chunk-GQRU4KJF.js} +3 -3
  59. package/dist/{chunk-4RF52LSU.js → chunk-HAXZ6CBF.js} +8 -8
  60. package/dist/{chunk-OEKFD5BC.js → chunk-IZVK6FKD.js} +1 -1
  61. package/dist/{chunk-KAVDAZ7A.js → chunk-L7NOG547.js} +1 -1
  62. package/dist/{chunk-D6PRC3YQ.js → chunk-LZQVAEWP.js} +2 -1
  63. package/dist/{chunk-WDKHZRAS.js → chunk-MC5WUOJJ.js} +41 -0
  64. package/dist/{chunk-6VANQN5Q.js → chunk-NKUE5SJB.js} +2 -2
  65. package/dist/{chunk-QQGNFHIK.js → chunk-PQGLDN4V.js} +2 -2
  66. package/dist/{chunk-CUEZMPLJ.js → chunk-QJPYJVL3.js} +1 -1
  67. package/dist/{chunk-RLXVW4AM.js → chunk-SDNTRS76.js} +1 -1
  68. package/dist/{chunk-4PX4QK2Z.js → chunk-SKPXQRKN.js} +1 -1
  69. package/dist/{chunk-VTO3TNIB.js → chunk-TY6IJY6I.js} +1 -1
  70. package/dist/{chunk-HQIVR7MM.js → chunk-U5RIXR7I.js} +1 -1
  71. package/dist/{chunk-WRDLHR2F.js → chunk-UKMLSQXA.js} +2 -2
  72. package/dist/{chunk-6AEM5RCS.js → chunk-USWT4PTR.js} +1 -1
  73. package/dist/{chunk-D62EONG3.js → chunk-VDI645ZX.js} +1 -1
  74. package/dist/{chunk-OI3DJA7N.js → chunk-VERXHIQF.js} +1 -1
  75. package/dist/{chunk-67YNQ3QA.js → chunk-VIUOGMT5.js} +18 -14
  76. package/dist/{chunk-S5CMLR35.js → chunk-VMQU7YEJ.js} +1 -1
  77. package/dist/{chunk-XLOELLAS.js → chunk-VMZ4SWQD.js} +4 -4
  78. package/dist/{chunk-32RMQO5Z.js → chunk-WAUAYRUQ.js} +47 -4
  79. package/dist/{chunk-VOSGZ6PH.js → chunk-WPS5IM7B.js} +3 -3
  80. package/dist/{chunk-HCMM3BOW.js → chunk-XJQOVWBQ.js} +14 -11
  81. package/dist/{chunk-KI4PA7MQ.js → chunk-XO3JR3OP.js} +90 -18
  82. package/dist/{chunk-IVQMNLSA.js → chunk-XUTSZYYJ.js} +1 -1
  83. package/dist/{chunk-2ZTL3PGN.js → chunk-XZWUOCJZ.js} +1 -1
  84. package/dist/{chunk-BDI2MXTT.js → chunk-Z4JVEL7R.js} +1 -1
  85. package/dist/src/index.d.ts +3 -0
  86. package/dist/src/index.js +10 -10
  87. package/package.json +1 -1
  88. package/dist/chunk-IIENZE2J.js +0 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.95.7
4
+
5
+ - Let Gemini's own prompt decide in When I'm out when Pushary cannot create the approval question, instead of refusing the call. Every time still refuses, and says Pushary could not create a verifiable approval.
6
+ - Refuse approvals an unattended Gemini bridge cannot show locally with a specific reason (no local input, no phone answer in time, handled on the machine, no device connected), instead of passing them to Gemini's unexplained headless denial.
7
+ - Withdraw the phone question before a Codex bridge gives up on a Terminal or Updates question, so an answer that arrives during the withdrawal still counts.
8
+ - Describe what happens when you don't answer in `pushary doctor` and `pushary wait` in the current mode words: When I'm out asks your phone when you are away or Pushary cannot tell, then hands back to the agent's own prompt.
9
+
10
+ ## 0.95.6
11
+
12
+ - Separate synthetic answer tests from real hook activation and report the actual answering surface.
13
+ - Cancel known pending diagnostic questions on interrupted or unanswered exits without fabricating approval.
14
+ - Add Claude Code activation evidence for a unique scratch-file approval, correlated with the observed tool result.
15
+ - Report managed-control readiness separately from hook configuration and phone delivery, refreshing credentials, service state and remote proof after interactive waits.
16
+
3
17
  ## 0.95.5
4
18
 
5
19
  - Preserve the full approval window while allowing the final poll time to return; keep late denials and cancellation authoritative.
@@ -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.2",
5
+ "version": "0.2.3",
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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3
4
+
5
+ - Updates and Terminal hand an approval back to Cursor promptly. The gate no longer waits for a phone answer that Updates never asks for, and it withdraws the phone question before Cursor decides. An answer that arrives in that moment still counts.
6
+ - A file change that goes back to the agent's own permissions is refused with the reason, because Cursor file hooks cannot show a local approval. Choose Every time for file changes you want to approve from your phone.
7
+ - With no phone or other notification channel connected, a file change is refused with a message that says so. Shell commands and MCP calls still open Cursor's own prompt with "No device connected, approve here."
8
+ - Approval questions carry the repository and the tool name the gate judged, so routing and repository rules apply to the question as they did to the gate. A file change is asked about with its full path, so path rules and routing match it. A path longer than 80 characters falls back to the file type instead of making the question fail.
9
+
3
10
  ## 0.2.2
4
11
 
5
12
  - Allow file edits when the server explicitly says the action is not gated.
@@ -5,7 +5,7 @@
5
5
  import { createHash, randomUUID } from 'node:crypto'
6
6
  import { hostname } from 'node:os'
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
- import { basename, dirname, join } from 'node:path'
8
+ import { basename, dirname, isAbsolute, join, posix, resolve } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
 
11
11
  const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || 'https://pushary.com'
@@ -22,6 +22,13 @@ const ALLOW = { permission: 'allow' }
22
22
  let genericToolHook = false
23
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' })
24
24
  const deny = (agentMessage) => ({ permission: 'deny', user_message: 'Command denied via Pushary.', agent_message: agentMessage })
25
+ // A quiet handoff returns to the agent's permissions, but a file hook has no
26
+ // local prompt to return to. Retrying cannot help, so say why. Same text as the gate route.
27
+ const QUIET_FILE_HOOK_REASON = 'This Cursor file hook cannot show a local approval. Switch to Every time for this action.'
28
+ const handBack = (agentMessage) => genericToolHook ? deny(QUIET_FILE_HOOK_REASON) : ask(agentMessage)
29
+ // No channel can reach anyone, and a file hook has no local prompt to fall back to either.
30
+ const NO_DEVICE_FILE_HOOK_REASON = 'No phone or other notification channel is connected to Pushary, and this Cursor file hook cannot show a local approval. Connect one in Pushary, then retry.'
31
+ const noDevice = () => genericToolHook ? deny(NO_DEVICE_FILE_HOOK_REASON) : ask('No device connected, approve here.')
25
32
  const unresolved = (verdict) => genericToolHook && verdict?.reason === 'not_gated'
26
33
  ? ALLOW
27
34
  : ask(genericToolHook ? 'Pushary could not reach a verdict for this change. Retry shortly.' : undefined)
@@ -317,7 +324,42 @@ const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
317
324
  // one-tap Approve on a dangerous call, and record a decision at the same grain as
318
325
  // every other agent. Without them a destructive Cursor command arrived on the
319
326
  // lock screen ungated.
320
- const commandHead = (command) => command.trim().split(/\s+/).slice(0, 2).join(' ').slice(0, 120)
327
+ // ask_user accepts a target of at most 80 characters (TOOL_TARGET_MAX_LENGTH in
328
+ // @pushary/contracts). A longer one fails the whole ask.
329
+ const TOOL_TARGET_MAX = 80
330
+
331
+ // A file change is routed and matched by its path, so the ask carries the path the
332
+ // gate judged, resolved against cwd the way the server resolves it. A path too long
333
+ // for ask_user is left out and the server's own target is used instead.
334
+ const filePathTarget = (path, cwd) => {
335
+ if (typeof path !== 'string' || !path) return undefined
336
+ const full = cwd && !isAbsolute(path) ? resolve(cwd, path) : path
337
+ return full.length <= TOOL_TARGET_MAX ? full : undefined
338
+ }
339
+
340
+ // ask_user takes toolPath as an absolute POSIX path of at most 4096 characters and
341
+ // rejects the whole ask for anything else.
342
+ const TOOL_PATH_MAX = 4096
343
+
344
+ // The exact path, so routing and path rules still match a file whose path is too
345
+ // long for toolTarget. Sent only when it is an absolute POSIX path: a relative path
346
+ // with no absolute cwd, or a Windows path, is left out rather than failing the ask.
347
+ const filePathForAsk = (path, cwd) => {
348
+ if (typeof path !== 'string' || !path) return undefined
349
+ if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\')) return undefined
350
+ const full = path.startsWith('/') ? path
351
+ : typeof cwd === 'string' && cwd.startsWith('/') ? posix.resolve(cwd, path) : undefined
352
+ return full && full.length <= TOOL_PATH_MAX ? full : undefined
353
+ }
354
+
355
+ // The canonical tool and target from a gate verdict, taken by name. Anything else a
356
+ // server adds to questionContext must never overwrite what this gate asks with.
357
+ const canonicalQuestion = (context) => ({
358
+ toolName: typeof context?.toolName === 'string' && context.toolName ? context.toolName : undefined,
359
+ toolTarget: typeof context?.toolTarget === 'string' && context.toolTarget ? context.toolTarget : undefined,
360
+ })
361
+
362
+ const commandHead = (command) => command.trim().split(/\s+/).slice(0, 2).join(' ').slice(0, TOOL_TARGET_MAX)
321
363
 
322
364
  const askArgs = (request, project, ident) => ({
323
365
  question: `${request.prompt}\n\n${redactSecrets(request.display)}`,
@@ -326,8 +368,10 @@ const askArgs = (request, project, ident) => ({
326
368
  agentName: ident.agentName,
327
369
  sessionId: ident.sessionId,
328
370
  machineId: ident.machineId,
371
+ repoKey: ident.repoKey,
329
372
  toolName: request.toolName,
330
373
  toolTarget: request.toolTarget,
374
+ ...(request.toolPath ? { toolPath: request.toolPath } : {}),
331
375
  actionBody: deriveActionBody(request.display),
332
376
  wait: false,
333
377
  waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
@@ -377,10 +421,11 @@ const withdraw = async (apiKey, correlationId) => {
377
421
  }
378
422
  }
379
423
 
380
- const handedOff = (asked) => asked.suppressed || asked.status === 'terminal'
424
+ const handedOff = (asked) => asked.suppressed || asked.status === 'terminal' || asked.status === 'notified'
425
+ || asked.handoffAction === 'cancel_then_ask_in_current_client'
381
426
 
382
427
  const handoffMessage = (asked) =>
383
- asked.suppressed ? 'You are at the keyboard, approve here.' : 'Delivery mode is Terminal, approve here.'
428
+ asked.suppressed ? 'You are at the keyboard, continue with the agent’s permissions.' : 'Continue with the agent’s permissions.'
384
429
 
385
430
  const denialFor = (noun) =>
386
431
  `The user denied this ${noun} via a Pushary push approval. Do not run it — propose an alternative or ask how to proceed.`
@@ -404,8 +449,10 @@ const describeRequest = (input) => {
404
449
  if (!['Write', 'Delete', 'Edit'].includes(input.tool_name)) return null
405
450
  const params = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}
406
451
  const display = `${input.tool_name}\n${JSON.stringify(params)}`
452
+ const toolPath = filePathForAsk(params.file_path ?? params.path, input.cwd)
407
453
  return { toolName: input.tool_name, toolInputs: [params], display,
408
- toolTarget: String(params.file_path ?? params.path ?? '').slice(0, 120),
454
+ toolTarget: filePathTarget(params.file_path ?? params.path, input.cwd),
455
+ ...(toolPath ? { toolPath } : {}),
409
456
  prompt: 'Allow this file change?', verb: 'change this file', denied: denialFor('file change') }
410
457
  }
411
458
 
@@ -434,7 +481,7 @@ const describeRequest = (input) => {
434
481
  toolName,
435
482
  toolInputs: [{ tool: toolName, params }],
436
483
  display: params ? `${toolName}\n${params}` : toolName,
437
- toolTarget: toolName.slice(0, 120),
484
+ toolTarget: toolName.slice(0, TOOL_TARGET_MAX),
438
485
  prompt: 'Allow this tool call?',
439
486
  verb: 'call this tool',
440
487
  denied: denialFor('tool call'),
@@ -471,17 +518,19 @@ const handlePushOnly = async (apiKey, request, project, ident, timeoutSeconds, t
471
518
  if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
472
519
  activeQuestion = { apiKey, correlationId: asked.correlationId }
473
520
 
474
- if (handedOff(asked)) {
521
+ // No device first. The server checks for a channel before any quiet mode, and
522
+ // that reply carries the same handoffAction as a quiet handoff.
523
+ if (asked.noDevices) {
475
524
  const late = await withdraw(apiKey, asked.correlationId)
476
525
  if (stopped(late)) return deny(STOP_REASON)
477
526
  if (late.answered) return fromAnswer(late, request.denied)
478
- return ask(handoffMessage(asked))
527
+ return noDevice()
479
528
  }
480
- if (asked.noDevices) {
529
+ if (handedOff(asked)) {
481
530
  const late = await withdraw(apiKey, asked.correlationId)
482
531
  if (stopped(late)) return deny(STOP_REASON)
483
532
  if (late.answered) return fromAnswer(late, request.denied)
484
- return ask('No device connected, approve here.')
533
+ return handBack(handoffMessage(asked))
485
534
  }
486
535
 
487
536
  const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
@@ -518,17 +567,17 @@ const handlePushFirst = async (apiKey, request, project, ident, pushFirstSeconds
518
567
  if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
519
568
  activeQuestion = { apiKey, correlationId: asked.correlationId }
520
569
 
521
- if (handedOff(asked)) {
570
+ if (asked.noDevices) {
522
571
  const late = await withdraw(apiKey, asked.correlationId)
523
572
  if (stopped(late)) return deny(STOP_REASON)
524
573
  if (late.answered) return fromAnswer(late, request.denied)
525
- return ask(handoffMessage(asked))
574
+ return noDevice()
526
575
  }
527
- if (asked.noDevices) {
576
+ if (handedOff(asked)) {
528
577
  const late = await withdraw(apiKey, asked.correlationId)
529
578
  if (stopped(late)) return deny(STOP_REASON)
530
579
  if (late.answered) return fromAnswer(late, request.denied)
531
- return ask('No device connected, approve here.')
580
+ return handBack(handoffMessage(asked))
532
581
  }
533
582
 
534
583
  const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
@@ -557,7 +606,7 @@ const handleNotifyOnly = async (apiKey, request, project, ident) => {
557
606
  machineId: ident.machineId,
558
607
  })
559
608
  } catch {}
560
- return ask()
609
+ return handBack()
561
610
  }
562
611
 
563
612
  const TELEMETRY_EVENTS = ["postToolUse", "stop", "sessionStart", "sessionEnd", "beforeSubmitPrompt", "preCompact", "subagentStart", "subagentStop"]
@@ -620,7 +669,7 @@ setTimeout(async () => {
620
669
 
621
670
  const project = basename(input.cwd || process.cwd()) || 'workspace'
622
671
  const sessionId = typeof input.conversation_id === 'string' ? input.conversation_id : undefined
623
- const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId() }
672
+ const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId(), repoKey: deriveRepoKey(input.cwd) }
624
673
 
625
674
  try {
626
675
  const verdict = await decide(apiKey, request, input.cwd, sessionId)
@@ -632,11 +681,17 @@ setTimeout(async () => {
632
681
  if (verdict.kind === 'deny') return respond(deny(verdict.reason))
633
682
  if (verdict.kind !== 'ask') return respond(ask())
634
683
 
684
+ // Reuse the server's canonical tool and target, so asking resolves the same rules
685
+ // as the gate. A file change keeps its own path: the server's target for a file
686
+ // is only its extension, which loses what routing and path rules match on.
687
+ const canonical = canonicalQuestion(verdict.questionContext)
688
+ request.toolName = canonical.toolName ?? request.toolName
689
+ request.toolTarget = (genericToolHook && request.toolTarget) || canonical.toolTarget || request.toolTarget
635
690
  const tool = verdict.policy
636
691
 
637
692
  switch (tool.mode) {
638
693
  case 'terminal_only':
639
- return respond(ask())
694
+ return respond(handBack())
640
695
  case 'notify_only':
641
696
  return respond(await handleNotifyOnly(apiKey, request, project, ident))
642
697
  case 'push_only':
@@ -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.2",
4
+ "version": "0.2.3",
5
5
  "author": {
6
6
  "name": "Pushary",
7
7
  "email": "business@pushary.com",
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3
4
+
5
+ - Updates hands an approval back to VS Code promptly. The gate no longer waits for a phone answer that Updates never asks for, and it withdraws the phone question before VS Code asks. An answer that arrives in that moment still counts.
6
+ - With no phone or other notification channel connected, VS Code's prompt says "No device connected, approve here." instead of naming a delivery mode.
7
+ - Approval questions carry the repository and the tool name the gate judged, so routing and repository rules apply to the question as they did to the gate. A file edit is asked about with its full path, so path rules and routing match it. A path longer than 80 characters falls back to the file type instead of making the question fail.
8
+
3
9
  ## 0.2.2
4
10
 
5
11
  - Classify local reads, searches, diagnostics, and terminal/task output inspection as reads under the shared policy.
@@ -5,7 +5,7 @@
5
5
  import { createHash, randomUUID } from 'node:crypto'
6
6
  import { homedir, hostname } from 'node:os'
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
- import { basename, dirname, join } from 'node:path'
8
+ import { basename, dirname, isAbsolute, join, posix, resolve } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
 
11
11
  const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || process.env.PUSHARY_API_URL?.trim() || 'https://pushary.com'
@@ -213,16 +213,8 @@ const GATE_TIMEOUT_MS = 5000
213
213
  * Null is not a denial and not an approval: every failure hands the call back to
214
214
  * VS Code's own prompt, exactly as if this gate were not installed.
215
215
  *
216
- * Sent as `cursor`, which is the wire's identity for an editor gate: a caller
217
- * with a hard block budget and no hook config of its own. The only consequence
218
- * is which policy profile answers, and a site without an explicit Cursor profile
219
- * falls back to the all-agents one, which is what this gate already received.
220
- *
221
- * No `repoKey` is sent, because deriving one means walking parent directories
222
- * for a `.git` and parsing its config, and a second implementation of that rule
223
- * is how the last drift started. Without one the server correctly declines to
224
- * apply repo-scoped rules, since an unknown repository is not evidence of a
225
- * match -- which is what this gate already got, by being filtered server-side.
216
+ * Uses the VS Code policy profile and the same repository identity carried
217
+ * by the later question. Tool aliases and targets are canonicalized server-side.
226
218
  */
227
219
  const decide = async (apiKey, command, cwd, sessionId, ident = {}) => {
228
220
  try {
@@ -424,6 +416,51 @@ export const describeNetworkFailure = (error, env = process.env) => {
424
416
  // Bearer ..."` left the machine and landed in the question. The server scrubs
425
417
  // this field too, but a credential should never travel to be scrubbed on
426
418
  // arrival, and the notify body below was scrubbed nowhere at all.
419
+ // ask_user accepts a target of at most 80 characters (TOOL_TARGET_MAX_LENGTH in
420
+ // @pushary/contracts). A longer one fails the whole ask.
421
+ const TOOL_TARGET_MAX = 80
422
+
423
+ // A file change is routed and matched by its path, so the ask carries the path the
424
+ // gate judged, resolved against cwd the way the server resolves it. A path too long
425
+ // for ask_user is left out and the server's own target is used instead.
426
+ const filePathTarget = (path, cwd) => {
427
+ if (typeof path !== 'string' || !path) return undefined
428
+ const full = cwd && !isAbsolute(path) ? resolve(cwd, path) : path
429
+ return full.length <= TOOL_TARGET_MAX ? full : undefined
430
+ }
431
+
432
+ // ask_user takes toolPath as an absolute POSIX path of at most 4096 characters and
433
+ // rejects the whole ask for anything else.
434
+ const TOOL_PATH_MAX = 4096
435
+
436
+ // The exact path, so routing and path rules still match a file whose path is too
437
+ // long for toolTarget. Sent only when it is an absolute POSIX path: a relative path
438
+ // with no absolute cwd, or a Windows path, is left out rather than failing the ask.
439
+ const filePathForAsk = (path, cwd) => {
440
+ if (typeof path !== 'string' || !path) return undefined
441
+ if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\')) return undefined
442
+ const full = path.startsWith('/') ? path
443
+ : typeof cwd === 'string' && cwd.startsWith('/') ? posix.resolve(cwd, path) : undefined
444
+ return full && full.length <= TOOL_PATH_MAX ? full : undefined
445
+ }
446
+
447
+ // The canonical tool and target from a gate verdict, taken by name. Anything else a
448
+ // server adds to questionContext must never overwrite what this gate asks with.
449
+ const canonicalQuestion = (context) => ({
450
+ toolName: typeof context?.toolName === 'string' && context.toolName ? context.toolName : undefined,
451
+ toolTarget: typeof context?.toolTarget === 'string' && context.toolTarget ? context.toolTarget : undefined,
452
+ })
453
+
454
+ // The tools the server targets by file (FILE_TARGET_TOOLS in @pushary/contracts).
455
+ const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
456
+
457
+ // The same fields the server reads a VS Code file tool's path from.
458
+ const extractFilePath = (toolInput) => {
459
+ if (!toolInput || typeof toolInput !== 'object') return undefined
460
+ const path = toolInput.file_path ?? toolInput.filePath ?? toolInput.path
461
+ return typeof path === 'string' && path ? path : undefined
462
+ }
463
+
427
464
  const askArgs = (command, project, ident) => ({
428
465
  question: `Allow this action?\n\n${redactSecrets(command)}`,
429
466
  type: 'confirm',
@@ -431,7 +468,10 @@ const askArgs = (command, project, ident) => ({
431
468
  agentName: ident.agentName,
432
469
  sessionId: ident.sessionId,
433
470
  machineId: ident.machineId,
471
+ repoKey: ident.repoKey,
434
472
  toolName: ident.toolName ?? 'Bash',
473
+ toolTarget: ident.toolTarget,
474
+ ...(ident.toolPath ? { toolPath: ident.toolPath } : {}),
435
475
  actionBody: deriveActionBody(command),
436
476
  wait: false,
437
477
  waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
@@ -483,10 +523,11 @@ const withdrawQuestion = async (apiKey, correlationId) => {
483
523
  }
484
524
  }
485
525
 
486
- const handedOff = (asked) => asked.suppressed || asked.status === 'terminal'
526
+ const handedOff = (asked) => asked.suppressed || asked.status === 'terminal' || asked.status === 'notified'
527
+ || asked.handoffAction === 'cancel_then_ask_in_current_client'
487
528
 
488
529
  const handoffMessage = (asked) =>
489
- asked.suppressed ? 'You are at the keyboard, approve here.' : 'Delivery mode is Terminal, approve here.'
530
+ asked.suppressed ? 'You are at the keyboard, continue with the agent’s permissions.' : 'Continue with the agent’s permissions.'
490
531
 
491
532
  // push_only: wait up to the policy timeout, then apply the timeout action.
492
533
  const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, timeoutAction) => {
@@ -502,19 +543,21 @@ const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, t
502
543
  if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
503
544
  activeQuestion = { apiKey, correlationId: asked.correlationId }
504
545
 
505
- // Keyboard bypass: the user is at the keyboard, so VS Code's own prompt is the
506
- // faster channel.
507
- if (handedOff(asked)) {
546
+ // No device first. The server checks for a channel before any quiet mode, and
547
+ // that reply carries the same handoffAction as a quiet handoff.
548
+ if (asked.noDevices) {
508
549
  const late = await withdrawQuestion(apiKey, asked.correlationId)
509
550
  if (stopped(late)) return deny(STOP_REASON)
510
551
  if (late.answered) return fromAnswer(late)
511
- return ask(handoffMessage(asked))
552
+ return ask('No device connected, approve here.')
512
553
  }
513
- if (asked.noDevices) {
554
+ // Keyboard bypass: the user is at the keyboard, so VS Code's own prompt is the
555
+ // faster channel.
556
+ if (handedOff(asked)) {
514
557
  const late = await withdrawQuestion(apiKey, asked.correlationId)
515
558
  if (stopped(late)) return deny(STOP_REASON)
516
559
  if (late.answered) return fromAnswer(late)
517
- return ask('No device connected, approve here.')
560
+ return ask(handoffMessage(asked))
518
561
  }
519
562
 
520
563
  const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
@@ -551,17 +594,17 @@ const handlePushFirst = async (apiKey, command, project, ident, pushFirstSeconds
551
594
  if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
552
595
  activeQuestion = { apiKey, correlationId: asked.correlationId }
553
596
 
554
- if (handedOff(asked)) {
597
+ if (asked.noDevices) {
555
598
  const late = await withdrawQuestion(apiKey, asked.correlationId)
556
599
  if (stopped(late)) return deny(STOP_REASON)
557
600
  if (late.answered) return fromAnswer(late)
558
- return ask(handoffMessage(asked))
601
+ return ask('No device connected, approve here.')
559
602
  }
560
- if (asked.noDevices) {
603
+ if (handedOff(asked)) {
561
604
  const late = await withdrawQuestion(apiKey, asked.correlationId)
562
605
  if (stopped(late)) return deny(STOP_REASON)
563
606
  if (late.answered) return fromAnswer(late)
564
- return ask('No device connected, approve here.')
607
+ return ask(handoffMessage(asked))
565
608
  }
566
609
 
567
610
  const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
@@ -665,7 +708,7 @@ const main = async () => {
665
708
 
666
709
  const project = basename(input.cwd || process.cwd()) || 'workspace'
667
710
  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 ?? {}] }
711
+ const ident = { agentName: `VS Code - ${project}`, sessionId, machineId: getMachineId(), toolName: input.tool_name, toolInputs: [input.tool_input ?? {}], repoKey: deriveRepoKey(input.cwd) }
669
712
 
670
713
  try {
671
714
  const verdict = await decide(apiKey, command, input.cwd, sessionId, ident)
@@ -678,6 +721,15 @@ const main = async () => {
678
721
  if (verdict.kind === 'deny') return respond(deny(verdict.reason))
679
722
  if (verdict.kind !== 'ask') return respond(ask())
680
723
 
724
+ // Reuse the server's canonical tool and target so asking resolves the same rules
725
+ // as the gate, taken by name so nothing else in questionContext can overwrite this
726
+ // gate's identity. A file change keeps its own path: the server's target for a
727
+ // file is only its extension, which loses what routing and path rules match on.
728
+ const canonical = canonicalQuestion(verdict.questionContext)
729
+ if (canonical.toolName) ident.toolName = canonical.toolName
730
+ const ownFile = FILE_TOOLS.has(ident.toolName) ? extractFilePath(input.tool_input) : undefined
731
+ ident.toolTarget = filePathTarget(ownFile, input.cwd) ?? canonical.toolTarget
732
+ ident.toolPath = filePathForAsk(ownFile, input.cwd)
681
733
  const tool = verdict.policy
682
734
 
683
735
  switch (tool.mode) {
@@ -1,36 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleBell
4
- } from "../chunk-VTO3TNIB.js";
4
+ } from "../chunk-TY6IJY6I.js";
5
5
  import {
6
6
  readHookInput
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";
7
+ } from "../chunk-IZVK6FKD.js";
8
+ import "../chunk-HAXZ6CBF.js";
9
+ import "../chunk-EMGGUO2Q.js";
10
+ import "../chunk-WPS5IM7B.js";
11
+ import "../chunk-XZWUOCJZ.js";
12
+ import "../chunk-QJPYJVL3.js";
13
+ import "../chunk-GQRU4KJF.js";
14
+ import "../chunk-VMZ4SWQD.js";
15
+ import "../chunk-7RNY3UHJ.js";
16
16
  import "../chunk-GOPIMNVC.js";
17
17
  import "../chunk-3AJIK5AO.js";
18
- import "../chunk-4PX4QK2Z.js";
18
+ import "../chunk-SKPXQRKN.js";
19
19
  import "../chunk-EQRS3SGM.js";
20
- import "../chunk-OI3DJA7N.js";
20
+ import "../chunk-VERXHIQF.js";
21
21
  import "../chunk-XHKBHWLX.js";
22
22
  import "../chunk-L4ZCFMWW.js";
23
- import "../chunk-IVQMNLSA.js";
24
- import "../chunk-N4VNLISN.js";
25
- import "../chunk-OW6AZDY2.js";
23
+ import "../chunk-XUTSZYYJ.js";
24
+ import "../chunk-4OSQZ5TB.js";
25
+ import "../chunk-BGX4QUQF.js";
26
26
  import "../chunk-GMXKITVA.js";
27
27
  import "../chunk-DINDL2CF.js";
28
- import "../chunk-S5CMLR35.js";
28
+ import "../chunk-VMQU7YEJ.js";
29
29
  import "../chunk-7QLSKOSU.js";
30
30
  import "../chunk-KZERVKTD.js";
31
- import "../chunk-KAVDAZ7A.js";
31
+ import "../chunk-L7NOG547.js";
32
32
  import "../chunk-2UMNXADU.js";
33
- import "../chunk-WDKHZRAS.js";
33
+ import "../chunk-MC5WUOJJ.js";
34
34
 
35
35
  // bin/pushary-bell-hook.ts
36
36
  var main = async () => {
@@ -5,7 +5,7 @@ import {
5
5
  liveAgentCount,
6
6
  ring,
7
7
  upgradeThreshold
8
- } from "../chunk-VTO3TNIB.js";
8
+ } from "../chunk-TY6IJY6I.js";
9
9
  import {
10
10
  addBellHooks,
11
11
  bellInstalled,
@@ -15,19 +15,19 @@ import "../chunk-3AJIK5AO.js";
15
15
  import "../chunk-L4ZCFMWW.js";
16
16
  import {
17
17
  claudeSettings
18
- } from "../chunk-OW6AZDY2.js";
18
+ } from "../chunk-BGX4QUQF.js";
19
19
  import {
20
20
  createIo
21
21
  } from "../chunk-5RUIFDTP.js";
22
22
  import {
23
23
  exitOnHelpFlag
24
- } from "../chunk-DLLAWCQB.js";
25
- import "../chunk-D6PRC3YQ.js";
24
+ } from "../chunk-GOQHN2UH.js";
25
+ import "../chunk-LZQVAEWP.js";
26
26
  import {
27
27
  parseFlags
28
28
  } from "../chunk-GMXKITVA.js";
29
29
  import "../chunk-DINDL2CF.js";
30
- import "../chunk-S5CMLR35.js";
30
+ import "../chunk-VMQU7YEJ.js";
31
31
  import "../chunk-7QLSKOSU.js";
32
32
  import {
33
33
  EXIT
@@ -35,9 +35,9 @@ import {
35
35
  import {
36
36
  readJsonSafe,
37
37
  writeJsonAtomic
38
- } from "../chunk-KAVDAZ7A.js";
38
+ } from "../chunk-L7NOG547.js";
39
39
  import "../chunk-2UMNXADU.js";
40
- import "../chunk-WDKHZRAS.js";
40
+ import "../chunk-MC5WUOJJ.js";
41
41
 
42
42
  // bin/pushary-bell.ts
43
43
  exitOnHelpFlag("bell");
@@ -8,7 +8,7 @@ import {
8
8
  describeIdleWindow,
9
9
  startProcessOwnership,
10
10
  withSpawnProcessOwnership
11
- } from "../chunk-QQGNFHIK.js";
11
+ } from "../chunk-PQGLDN4V.js";
12
12
  import {
13
13
  isDeferAnswer
14
14
  } from "../chunk-YHG74UFF.js";
@@ -17,25 +17,25 @@ import {
17
17
  loadSessionKey,
18
18
  registerTranscript,
19
19
  registeredTranscriptEnabled
20
- } from "../chunk-4RF52LSU.js";
21
- import "../chunk-BMANRGJQ.js";
22
- import "../chunk-VOSGZ6PH.js";
23
- import "../chunk-2ZTL3PGN.js";
24
- import "../chunk-CUEZMPLJ.js";
25
- import "../chunk-HBHEX3JQ.js";
20
+ } from "../chunk-HAXZ6CBF.js";
21
+ import "../chunk-EMGGUO2Q.js";
22
+ import "../chunk-WPS5IM7B.js";
23
+ import "../chunk-XZWUOCJZ.js";
24
+ import "../chunk-QJPYJVL3.js";
25
+ import "../chunk-GQRU4KJF.js";
26
26
  import {
27
27
  ensureDaemonRunning
28
- } from "../chunk-XLOELLAS.js";
29
- import "../chunk-2D7BAOMY.js";
28
+ } from "../chunk-VMZ4SWQD.js";
29
+ import "../chunk-7RNY3UHJ.js";
30
30
  import "../chunk-GOPIMNVC.js";
31
31
  import "../chunk-3AJIK5AO.js";
32
32
  import {
33
33
  encryptRecord
34
- } from "../chunk-4PX4QK2Z.js";
34
+ } from "../chunk-SKPXQRKN.js";
35
35
  import "../chunk-EQRS3SGM.js";
36
36
  import {
37
37
  transcriptsEnabled
38
- } from "../chunk-OI3DJA7N.js";
38
+ } from "../chunk-VERXHIQF.js";
39
39
  import {
40
40
  needsShell,
41
41
  spawnClaude
@@ -54,7 +54,14 @@ import {
54
54
  repoKeyFor,
55
55
  reportEvent,
56
56
  savePendingCommand
57
- } from "../chunk-HCMM3BOW.js";
57
+ } from "../chunk-XJQOVWBQ.js";
58
+ import {
59
+ UNNAMED_ACTION,
60
+ deriveToolTarget,
61
+ describeToolCall,
62
+ scopePathFor
63
+ } from "../chunk-Z4JVEL7R.js";
64
+ import "../chunk-XUTSZYYJ.js";
58
65
  import {
59
66
  DECISION_STOP_REASON,
60
67
  askUser,
@@ -65,25 +72,18 @@ import {
65
72
  waitForAnswer,
66
73
  withdrawQuestion
67
74
  } from "../chunk-XS33QBJA.js";
68
- import {
69
- UNNAMED_ACTION,
70
- deriveToolTarget,
71
- describeToolCall,
72
- scopePathFor
73
- } from "../chunk-BDI2MXTT.js";
74
- import "../chunk-IVQMNLSA.js";
75
75
  import {
76
76
  getMachineId
77
- } from "../chunk-N4VNLISN.js";
78
- import "../chunk-OW6AZDY2.js";
77
+ } from "../chunk-4OSQZ5TB.js";
78
+ import "../chunk-BGX4QUQF.js";
79
79
  import "../chunk-GMXKITVA.js";
80
80
  import "../chunk-DINDL2CF.js";
81
- import "../chunk-S5CMLR35.js";
81
+ import "../chunk-VMQU7YEJ.js";
82
82
  import "../chunk-EYRGBYZG.js";
83
83
  import "../chunk-SAF6HGAA.js";
84
84
  import "../chunk-7QLSKOSU.js";
85
85
  import "../chunk-KZERVKTD.js";
86
- import "../chunk-KAVDAZ7A.js";
86
+ import "../chunk-L7NOG547.js";
87
87
  import {
88
88
  getApiKey,
89
89
  getBaseUrl
@@ -92,7 +92,7 @@ import {
92
92
  KILL_REASON,
93
93
  redactSecrets,
94
94
  resolveGate
95
- } from "../chunk-WDKHZRAS.js";
95
+ } from "../chunk-MC5WUOJJ.js";
96
96
 
97
97
  // src/wrapper/localPassthrough.ts
98
98
  var SIGNAL_NUMBERS = {