@pushary/agent-hooks 0.80.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.81.0
4
+
5
+ ### The idle ping now honours a Terminal mode set in the dashboard
6
+
7
+ "Your agent is waiting" pushes checked your delivery mode by reading the
8
+ temporary override only. Choosing Terminal in the dashboard or the app
9
+ does not write an override, it writes a standing rule, so the check never
10
+ fired and the ping buzzed a phone whose owner had asked not to be reached.
11
+
12
+ The ping now declares itself a task update, which puts it behind the same
13
+ server-side gate as everything else the agent sends unprompted. That gate
14
+ sees the standing rule, the kill switch and a mute. It also means your
15
+ updates dial governs idle pings: set updates to Off and they stop.
16
+
17
+ ## 0.80.1
18
+
19
+ ### The bell's heartbeat directory is private to you now
20
+
21
+ `pushary bell` counts how many agents are live by keeping one empty file
22
+ per session in the system temp directory. On macOS that is already a
23
+ per-user directory, so this was fine. On Linux it is the shared `/tmp`,
24
+ where it was not: the first user to create `pushary-bell` owned it, and
25
+ every other user on the box got a permission error and silently counted
26
+ zero agents. Anyone who could write to it could also inflate the count
27
+ and trigger the upgrade line.
28
+
29
+ The directory is now per uid and `0700`, the heartbeats inside it `0600`,
30
+ and the sweep judges an entry with `lstat` so a planted symlink is dropped
31
+ rather than followed. Nothing about the bell itself changes, and it still
32
+ makes no network calls.
33
+
3
34
  ## 0.80.0
4
35
 
5
36
  ### A bell, free, for when you only need to know it finished
package/data/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pushary
3
- version: 0.8.0
4
- description: Push notifications and human-in-the-loop for AI agents. Use this whenever a running agent needs a human and nobody is at the terminal: before an irreversible or destructive action, before spending money, deploying, force-pushing or deleting, when blocked on a decision outside your authority, when running unattended and you hit a genuine ambiguity, when another skill's workflow says to confirm with the user, and when a long task finishes or fails with nobody watching. Also use it when the user says things like keep going and ping me on my phone if you need anything, notify me when my agent needs me, approve from my phone, ask me questions while I am away from the terminal, run this overnight, keep working while I am in a meeting, I am stepping away, do not wait for me, or wants a long task to run unattended. Send alerts when tasks finish or fail, ask questions (yes/no, multiple choice, or free text) via push, and get answers from the user's lock screen. Use these tools proactively - do not wait for the user to ask for notifications. Works with Claude Code, Codex, Cursor, VS Code, Windsurf, Hermes, Lovable, or any MCP client; no Claude Max subscription required. Pushary is a hosted service, $9.99/mo after a 3-day card-first trial.
3
+ version: 0.9.0
4
+ description: Push notifications and human-in-the-loop for AI agents. Use this whenever a running agent needs a human and nobody is at the terminal: before an irreversible or destructive action, before spending money, deploying, force-pushing or deleting, when blocked on a decision outside your authority, when running unattended and you hit a genuine ambiguity, when another skill's workflow says to confirm with the user, and when a long task finishes or fails with nobody watching. Also use it when the user says things like keep going and ping me on my phone if you need anything, notify me when my agent needs me, approve from my phone, ask me questions while I am away from the terminal, run this overnight, keep working while I am in a meeting, I am stepping away, do not wait for me, or wants a long task to run unattended. Send alerts when tasks finish or fail, ask questions (yes/no, multiple choice, or free text) via push, and get answers from the user's lock screen. Use these tools proactively - do not wait for the user to ask for notifications. Every question and answer is recorded, so an unattended run stays reviewable afterwards. Works with Claude Code, Codex, Cursor, VS Code, Windsurf, Hermes, Lovable, or any MCP client; no Claude Max subscription required. Pushary is a hosted service, $9.99/mo after a 3-day card-first trial.
5
5
  metadata:
6
6
  hermes:
7
7
  tags: [notifications, push, human-in-the-loop, alerts, permissions, mcp]
@@ -41,6 +41,19 @@ Pushary is a hosted service. It costs $9.99/mo after a 3-day trial, and the tria
41
41
 
42
42
  If the user runs Claude Code with a Claude Max subscription, Anthropic Remote Control is free for that one setup. Recommend it when that is all they need. Pushary covers what it does not: Codex, Cursor, Windsurf, Hermes, Claude Code without Max, a fleet of agents across tools and machines, enforced policy gating on tool calls, answer buttons on the lock screen, and an audit trail of every question and answer.
43
43
 
44
+ ## Plan the Questions Before You Start
45
+
46
+ Every question costs the user their attention wherever they happen to be. That cost is the only real limit on this tool, so spend it deliberately. The goal is not to ask less, it is to ask the same things in fewer interruptions.
47
+
48
+ Before a run of more than a step or two, work out where you will need a human, then fold those points together:
49
+
50
+ - **A fork you find while planning can be merged into one question.** A fork you find halfway through costs its own interruption. Finding them early is the whole saving.
51
+ - **One `select` carrying the real options beats three sequential `confirm`s.** Same information, a third of the interruptions.
52
+ - **Ask once at the boundary, not once per instance.** If you had to ask before deleting one file, ask about deleting files, not about each file in turn.
53
+ - **Never ask what you can determine.** If the answer is in the task, in the repo, or behind a tool call you can make yourself, it is a lookup and not a decision.
54
+
55
+ `propose_scope` is the strongest version of this: one approval at the start buys the whole run. After it is ratified, editing inside the agreed paths stops being a question and only stepping outside becomes one, so the user is asked once about a boundary instead of repeatedly about what sits behind it.
56
+
44
57
  ## When to Use
45
58
 
46
59
  **Send a notification when:**
@@ -119,54 +132,23 @@ npx @pushary/agent-hooks@latest doctor
119
132
 
120
133
  ## Tools
121
134
 
135
+ Every parameter and every returned field is described in each tool's own schema,
136
+ which your client already has and which is always current. What follows is only
137
+ what a schema cannot tell you: when to reach for a tool, what its result means for
138
+ what you do next, and the shapes that are easy to get wrong.
139
+
122
140
  ### send_notification
123
141
 
124
142
  Send a one-way push notification to the user. Optionally include structured context for a rich detail page.
125
143
 
126
- **Parameters:**
127
-
128
- | Name | Type | Required | Description |
129
- |------|------|----------|-------------|
130
- | title | string | Yes | Notification title (max 100 chars, aim for under 60) |
131
- | body | string | Yes | Notification body (max 500 chars, aim for under 200) |
132
- | url | string | No | URL opened when tapped. Ignored if context is provided. |
133
- | agentName | string | No | Identifies which agent sent this (e.g., "Claude Code - myproject") |
134
- | iconUrl | string | No | Custom notification icon URL |
135
- | imageUrl | string | No | Large image shown in the notification |
136
- | sessionId | string | No | Opaque per-session id of the sending agent, so parallel sessions are attributed separately (max 128 chars) |
137
- | machineId | string | No | Stable machine id of the sending agent, so two machines never collapse into one session (max 128 chars) |
138
- | subscriberIds | string[] | No | Target specific subscriber IDs |
139
- | externalIds | string[] | No | Target by external IDs |
140
- | tags | string[] | No | Target by subscriber tags |
141
- | context | object | Yes for task updates | Structured context for a rich detail page (see below). `context.type` marks the notification a task update, and the user's setting for where task updates land can only route one that says so. |
142
-
143
- **Context object:**
144
-
145
- | Name | Type | Description |
146
- |------|------|-------------|
147
- | type | "task_complete" / "error" / "info" | The kind of notification |
148
- | summary | string | Short summary of what happened |
149
- | details | string[] | Bullet-point details |
150
- | filesChanged | string[] | List of files that were changed |
151
- | errorMessage | string | Error message (for error type) |
152
- | errorFile | string | File path where the error occurred |
153
- | nextSteps | string | Suggested next steps for the user |
154
- | askQuestion | object | Embed a decision prompt in the notification (see below) |
155
-
156
- **Embedded askQuestion:**
157
-
158
- | Name | Type | Description |
159
- |------|------|-------------|
160
- | question | string | A follow-up question shown below the context |
161
- | type | "confirm" / "select" / "input" | Question type (default: confirm) |
162
- | options | string[] | Options for select type (2-6 items) |
163
-
164
- When `askQuestion` is provided, the response includes a `linkedCorrelationId` you pass to `wait_for_answer`.
165
-
166
- **Returns:**
167
- - `delivery` - per-channel result: `{ "web": { "recipients": <n> }, "mobile": { "recipients": <n> } }` (each channel may also include a `status` like `no_recipients` or `not_configured`)
168
- - `sent` - total devices reached across all channels
169
- - `warning` - present only when the notification reached 0 devices because no phone or browser is connected; the user must connect one in the dashboard under Settings then Connections
144
+ `context.type` is what marks a notification a **task update**, and the user's
145
+ setting for where task updates land can only route one that says so. A
146
+ notification sent without it reaches them wherever the default sends it.
147
+
148
+ On a long run where the user is likely away, prefer `context.askQuestion` over a
149
+ blocking `ask_user`. They get an ordinary push and answer whenever they next pick
150
+ up their phone, rather than you holding a 55-second wait open against someone who
151
+ is not there. Poll the returned `linkedCorrelationId` when you need the result.
170
152
 
171
153
  **Example - task completed with context:**
172
154
 
@@ -209,36 +191,15 @@ When `askQuestion` is provided, the response includes a `linkedCorrelationId` yo
209
191
 
210
192
  Send a question to the user via push notification and wait for their answer. By default, this tool **blocks** until the user responds or the timeout is reached - no need to call `wait_for_answer` separately.
211
193
 
212
- **Parameters:**
213
-
214
- | Name | Type | Required | Description |
215
- |------|------|----------|-------------|
216
- | question | string | Yes | The question to ask (max 500 chars) |
217
- | type | "confirm" / "select" / "input" | No | Question type (default: confirm) |
218
- | options | string[] | No | Choices for select type (2-6 options). Required when type is select. |
219
- | placeholder | string | No | Placeholder text for input type (max 200 chars) |
220
- | context | string | No | What the agent is working on, shown above the question (max 500 chars) |
221
- | wait | boolean | No | Wait for the answer before returning (default: true). Set false for manual polling. |
222
- | timeoutMs | integer | No | Max wait time in ms (max 55000). Uses site policy if omitted. |
223
- | agentName | string | No | Identifies which agent is asking. Format: "{Agent} - {project}" (e.g., "Claude Code - myproject") |
224
- | sessionId | string | No | Opaque per-session id of the asking agent, so parallel sessions are attributed separately (max 128 chars) |
225
- | machineId | string | No | Stable machine id of the asking agent, so two machines never collapse into one session (max 128 chars) |
226
- | toolName | string | No | The tool this approval is for (e.g. "Bash"), so the user can choose to always-allow it (max 100 chars) |
227
- | toolTarget | string | No | Compact target of the tool call (e.g. command head "git push" for Bash, or a file extension like ".ts" for Edit/Write). Used to mine always-allow policy suggestions (max 80 chars) |
228
- | callbackUrl | string | No | Webhook URL to POST the answer to when the user responds |
229
- | subscriberIds | string[] | No | Target specific subscriber IDs |
230
- | externalIds | string[] | No | Target by external IDs |
231
- | tags | string[] | No | Target by subscriber tags |
232
-
233
- **Returns (when wait=true, default):**
234
- - `{ "answered": true, "value": "yes", "correlationId": "uuid" }` - user responded
235
- - `{ "answered": false, "timedOut": true, "correlationId": "uuid" }` - timeout reached
236
-
237
- **Returns (when wait=false):**
238
- - `{ "correlationId": "uuid", "status": "pending", "expiresInSeconds": 600 }` - use `wait_for_answer` to poll
239
-
240
- **Returns (when the site policy is notify_only):**
241
- - `{ "correlationId": "uuid", "status": "notified", "answered": false, "mode": "notify_only" }` - the question was pushed but no answer was awaited (the user gets a heads-up, not a blocking prompt). Call `wait_for_answer` if you want to poll for a response anyway.
194
+ Always read `answered` rather than assuming the call blocked. It comes back false
195
+ in three different situations that mean different things: the wait timed out and
196
+ the question is still live (`timedOut`), the site policy is notify_only so nothing
197
+ was awaited (`status: "notified"`), or you passed `wait: false` yourself
198
+ (`status: "pending"`). All three leave a `correlationId` you can poll.
199
+
200
+ Pass `toolName` and `toolTarget` whenever the question is an approval for a tool
201
+ call. They are what let the user turn a repeated approval into an always-allow
202
+ rule, so an approval you label once is an approval they never see again.
242
203
 
243
204
  **Example - confirm (yes/no):**
244
205
 
@@ -277,28 +238,19 @@ Send a question to the user via push notification and wait for their answer. By
277
238
 
278
239
  ### wait_for_answer
279
240
 
280
- Poll for the user's response to a question sent via `ask_user` with `wait: false`. Not needed when using the default blocking mode.
281
-
282
- **Parameters:**
241
+ Poll for the user's response to a question sent via `ask_user` with `wait: false`, or to one that timed out. Not needed when using the default blocking mode.
283
242
 
284
- | Name | Type | Required | Description |
285
- |------|------|----------|-------------|
286
- | correlationId | string (uuid) | Yes | The correlationId from ask_user |
287
- | timeoutMs | integer | No | How long to wait (default 30000, max 55000) |
288
-
289
- **Returns:**
290
- - `{ "answered": true, "value": "yes" }` - user responded
291
- - `{ "answered": false }` - timeout reached, no answer yet
243
+ A single call waits at most 55 seconds but the question stays answerable for 10
244
+ minutes, so one empty return is not a refusal. Retry with the same
245
+ `correlationId` up to three times at `timeoutMs: 55000` before treating it as
246
+ unanswered.
292
247
 
293
248
  ### cancel_question
294
249
 
295
250
  Cancel a pending question so it can no longer be answered. Use when the question becomes irrelevant (e.g., you found the answer another way or the user responded in chat).
296
251
 
297
- **Parameters:**
298
-
299
- | Name | Type | Required | Description |
300
- |------|------|----------|-------------|
301
- | correlationId | string (uuid) | Yes | The correlationId of the question to cancel |
252
+ A stale approval arriving twenty minutes later is worse than no approval, because
253
+ it reads as consent to work that has already moved on.
302
254
 
303
255
  ### propose_scope
304
256
 
@@ -308,21 +260,13 @@ The user sees the paths you intend to change, the areas you promise to leave alo
308
260
 
309
261
  Use glob syntax (`src/**`, `**/*.test.ts`). Shell commands are **not** scoped here; they stay governed by the permission policy.
310
262
 
311
- **Parameters:**
263
+ `ratified` and `answered` are separate on purpose. Answered but not ratified means
264
+ the user declined: ask what scope they want, and do **not** proceed as if they had
265
+ agreed. Not answered means the scope is simply not in force.
312
266
 
313
- | Name | Type | Required | Description |
314
- |------|------|----------|-------------|
315
- | doneWhen | string | Yes | What "finished" means for this run. Carried for the human to judge against, never enforced automatically |
316
- | sessionId | string | Yes | Your per-session id. A scope with no session cannot be enforced and must never leak into another run |
317
- | allowedPaths | string[] | No | Globs you intend to change. Omit to propose no path restriction, which the user is told plainly |
318
- | offLimitsPaths | string[] | No | Globs you promise not to touch. These win wherever they overlap `allowedPaths` |
319
- | agentName | string | No | Name of the agent asking, format `"{Agent} - {project}"` |
320
- | timeoutMs | integer | No | How long this call blocks, max 55000 |
321
-
322
- **Returns:**
323
- - `{ "ratified": true, "answered": true, "value": "yes", "contract": {...} }` - the contract is live
324
- - `{ "ratified": false, "answered": true, "value": "no" }` - the user declined. Ask what scope they want; do **not** proceed as if they agreed
325
- - `{ "ratified": false, "answered": false }` - no answer yet. The scope is **not** in force
267
+ Omitting `allowedPaths` proposes no path restriction, and the user is told that
268
+ plainly as "this agent is asking to touch anything", so omit it only when you mean
269
+ it.
326
270
 
327
271
  **What enforcement depends on.** The contract is recorded and shown to the user by any MCP client. Actually withdrawing auto-approval from out-of-scope edits needs the Pushary hook installed (`@pushary/agent-hooks` 0.59.0 or later), which is how Claude Code, Codex and Gemini CLI run. Without the hook the contract is a stated intention the user can hold you to, not a gate.
328
272
 
@@ -334,15 +278,9 @@ Scope lives for the session only and is never inherited by another run.
334
278
 
335
279
  Read-only. Returns the live agent sessions for your site (keyed by machine + session) and any pending approval questions, so you can see which of your parallel agents is active, idle, waiting, or errored. Does NOT start, stop, or steer agents, and sends no notification. Useful when you are one of several agents and want to check whether another session is blocked on a question before acting.
336
280
 
337
- **Parameters:**
338
-
339
- | Name | Type | Required | Description |
340
- |------|------|----------|-------------|
341
- | status | "active" / "idle" / "waiting" / "errored" | No | Filter to sessions with this computed status |
342
- | limit | integer | No | Max number of sessions to return, most recently seen first (1-100) |
343
-
344
- **Returns:**
345
- - `{ "sessions": [...], "pendingQuestions": [...] }` - current sessions and open questions for the site
281
+ Check it before asking when you are one of several agents: if another session is
282
+ already blocked on a question, adding a second one competes for the same
283
+ attention rather than getting you answered sooner.
346
284
 
347
285
  ## Permission Gating (REQUIRED)
348
286
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pushary
3
- description: Push notifications and human-in-the-loop for AI agents. Use this whenever a running agent needs a human and nobody is at the terminal: before an irreversible or destructive action, before spending money, deploying, force-pushing or deleting, when blocked on a decision outside your authority, when running unattended and you hit a genuine ambiguity, when another skill's workflow says to confirm with the user, and when a long task finishes or fails with nobody watching. Also use it when the user says things like keep going and ping me on my phone if you need anything, notify me when my agent needs me, approve from my phone, ask me questions while I am away from the terminal, run this overnight, keep working while I am in a meeting, I am stepping away, do not wait for me, or wants a long task to run unattended. Send alerts when tasks finish or fail, ask questions (yes/no, multiple choice, or free text) via push, and get answers from the user's lock screen. Use these tools proactively - do not wait for the user to ask for notifications. Works with Claude Code, Codex, Cursor, VS Code, Windsurf, Hermes, Lovable, or any MCP client; no Claude Max subscription required. Pushary is a hosted service, $9.99/mo after a 3-day card-first trial.
3
+ description: Push notifications and human-in-the-loop for AI agents. Use this whenever a running agent needs a human and nobody is at the terminal: before an irreversible or destructive action, before spending money, deploying, force-pushing or deleting, when blocked on a decision outside your authority, when running unattended and you hit a genuine ambiguity, when another skill's workflow says to confirm with the user, and when a long task finishes or fails with nobody watching. Also use it when the user says things like keep going and ping me on my phone if you need anything, notify me when my agent needs me, approve from my phone, ask me questions while I am away from the terminal, run this overnight, keep working while I am in a meeting, I am stepping away, do not wait for me, or wants a long task to run unattended. Send alerts when tasks finish or fail, ask questions (yes/no, multiple choice, or free text) via push, and get answers from the user's lock screen. Use these tools proactively - do not wait for the user to ask for notifications. Every question and answer is recorded, so an unattended run stays reviewable afterwards. Works with Claude Code, Codex, Cursor, VS Code, Windsurf, Hermes, Lovable, or any MCP client; no Claude Max subscription required. Pushary is a hosted service, $9.99/mo after a 3-day card-first trial.
4
4
  ---
5
5
 
6
6
  # Pushary - Push Notifications for AI Agents
@@ -34,6 +34,19 @@ Pushary is a hosted service. It costs $9.99/mo after a 3-day trial, and the tria
34
34
 
35
35
  If the user runs Claude Code with a Claude Max subscription, Anthropic Remote Control is free for that one setup. Recommend it when that is all they need. Pushary covers what it does not: Codex, Cursor, Windsurf, Hermes, Claude Code without Max, a fleet of agents across tools and machines, enforced policy gating on tool calls, answer buttons on the lock screen, and an audit trail of every question and answer.
36
36
 
37
+ ## Plan the Questions Before You Start
38
+
39
+ Every question costs the user their attention wherever they happen to be. That cost is the only real limit on this tool, so spend it deliberately. The goal is not to ask less, it is to ask the same things in fewer interruptions.
40
+
41
+ Before a run of more than a step or two, work out where you will need a human, then fold those points together:
42
+
43
+ - **A fork you find while planning can be merged into one question.** A fork you find halfway through costs its own interruption. Finding them early is the whole saving.
44
+ - **One `select` carrying the real options beats three sequential `confirm`s.** Same information, a third of the interruptions.
45
+ - **Ask once at the boundary, not once per instance.** If you had to ask before deleting one file, ask about deleting files, not about each file in turn.
46
+ - **Never ask what you can determine.** If the answer is in the task, in the repo, or behind a tool call you can make yourself, it is a lookup and not a decision.
47
+
48
+ `propose_scope` is the strongest version of this: one approval at the start buys the whole run. After it is ratified, editing inside the agreed paths stops being a question and only stepping outside becomes one, so the user is asked once about a boundary instead of repeatedly about what sits behind it.
49
+
37
50
  ## When to Use
38
51
 
39
52
  **Send a notification when:**
@@ -112,54 +125,23 @@ npx @pushary/agent-hooks@latest doctor
112
125
 
113
126
  ## Tools
114
127
 
128
+ Every parameter and every returned field is described in each tool's own schema,
129
+ which your client already has and which is always current. What follows is only
130
+ what a schema cannot tell you: when to reach for a tool, what its result means for
131
+ what you do next, and the shapes that are easy to get wrong.
132
+
115
133
  ### send_notification
116
134
 
117
135
  Send a one-way push notification to the user. Optionally include structured context for a rich detail page.
118
136
 
119
- **Parameters:**
120
-
121
- | Name | Type | Required | Description |
122
- |------|------|----------|-------------|
123
- | title | string | Yes | Notification title (max 100 chars, aim for under 60) |
124
- | body | string | Yes | Notification body (max 500 chars, aim for under 200) |
125
- | url | string | No | URL opened when tapped. Ignored if context is provided. |
126
- | agentName | string | No | Identifies which agent sent this (e.g., "Claude Code - myproject") |
127
- | iconUrl | string | No | Custom notification icon URL |
128
- | imageUrl | string | No | Large image shown in the notification |
129
- | sessionId | string | No | Opaque per-session id of the sending agent, so parallel sessions are attributed separately (max 128 chars) |
130
- | machineId | string | No | Stable machine id of the sending agent, so two machines never collapse into one session (max 128 chars) |
131
- | subscriberIds | string[] | No | Target specific subscriber IDs |
132
- | externalIds | string[] | No | Target by external IDs |
133
- | tags | string[] | No | Target by subscriber tags |
134
- | context | object | Yes for task updates | Structured context for a rich detail page (see below). `context.type` marks the notification a task update, and the user's setting for where task updates land can only route one that says so. |
135
-
136
- **Context object:**
137
-
138
- | Name | Type | Description |
139
- |------|------|-------------|
140
- | type | "task_complete" / "error" / "info" | The kind of notification |
141
- | summary | string | Short summary of what happened |
142
- | details | string[] | Bullet-point details |
143
- | filesChanged | string[] | List of files that were changed |
144
- | errorMessage | string | Error message (for error type) |
145
- | errorFile | string | File path where the error occurred |
146
- | nextSteps | string | Suggested next steps for the user |
147
- | askQuestion | object | Embed a decision prompt in the notification (see below) |
148
-
149
- **Embedded askQuestion:**
150
-
151
- | Name | Type | Description |
152
- |------|------|-------------|
153
- | question | string | A follow-up question shown below the context |
154
- | type | "confirm" / "select" / "input" | Question type (default: confirm) |
155
- | options | string[] | Options for select type (2-6 items) |
156
-
157
- When `askQuestion` is provided, the response includes a `linkedCorrelationId` you pass to `wait_for_answer`.
158
-
159
- **Returns:**
160
- - `delivery` - per-channel result: `{ "web": { "recipients": <n> }, "mobile": { "recipients": <n> } }` (each channel may also include a `status` like `no_recipients` or `not_configured`)
161
- - `sent` - total devices reached across all channels
162
- - `warning` - present only when the notification reached 0 devices because no phone or browser is connected; the user must connect one in the dashboard under Settings then Connections
137
+ `context.type` is what marks a notification a **task update**, and the user's
138
+ setting for where task updates land can only route one that says so. A
139
+ notification sent without it reaches them wherever the default sends it.
140
+
141
+ On a long run where the user is likely away, prefer `context.askQuestion` over a
142
+ blocking `ask_user`. They get an ordinary push and answer whenever they next pick
143
+ up their phone, rather than you holding a 55-second wait open against someone who
144
+ is not there. Poll the returned `linkedCorrelationId` when you need the result.
163
145
 
164
146
  **Example - task completed with context:**
165
147
 
@@ -202,36 +184,15 @@ When `askQuestion` is provided, the response includes a `linkedCorrelationId` yo
202
184
 
203
185
  Send a question to the user via push notification and wait for their answer. By default, this tool **blocks** until the user responds or the timeout is reached - no need to call `wait_for_answer` separately.
204
186
 
205
- **Parameters:**
206
-
207
- | Name | Type | Required | Description |
208
- |------|------|----------|-------------|
209
- | question | string | Yes | The question to ask (max 500 chars) |
210
- | type | "confirm" / "select" / "input" | No | Question type (default: confirm) |
211
- | options | string[] | No | Choices for select type (2-6 options). Required when type is select. |
212
- | placeholder | string | No | Placeholder text for input type (max 200 chars) |
213
- | context | string | No | What the agent is working on, shown above the question (max 500 chars) |
214
- | wait | boolean | No | Wait for the answer before returning (default: true). Set false for manual polling. |
215
- | timeoutMs | integer | No | Max wait time in ms (max 55000). Uses site policy if omitted. |
216
- | agentName | string | No | Identifies which agent is asking. Format: "{Agent} - {project}" (e.g., "Claude Code - myproject") |
217
- | sessionId | string | No | Opaque per-session id of the asking agent, so parallel sessions are attributed separately (max 128 chars) |
218
- | machineId | string | No | Stable machine id of the asking agent, so two machines never collapse into one session (max 128 chars) |
219
- | toolName | string | No | The tool this approval is for (e.g. "Bash"), so the user can choose to always-allow it (max 100 chars) |
220
- | toolTarget | string | No | Compact target of the tool call (e.g. command head "git push" for Bash, or a file extension like ".ts" for Edit/Write). Used to mine always-allow policy suggestions (max 80 chars) |
221
- | callbackUrl | string | No | Webhook URL to POST the answer to when the user responds |
222
- | subscriberIds | string[] | No | Target specific subscriber IDs |
223
- | externalIds | string[] | No | Target by external IDs |
224
- | tags | string[] | No | Target by subscriber tags |
225
-
226
- **Returns (when wait=true, default):**
227
- - `{ "answered": true, "value": "yes", "correlationId": "uuid" }` - user responded
228
- - `{ "answered": false, "timedOut": true, "correlationId": "uuid" }` - timeout reached
229
-
230
- **Returns (when wait=false):**
231
- - `{ "correlationId": "uuid", "status": "pending", "expiresInSeconds": 600 }` - use `wait_for_answer` to poll
232
-
233
- **Returns (when the site policy is notify_only):**
234
- - `{ "correlationId": "uuid", "status": "notified", "answered": false, "mode": "notify_only" }` - the question was pushed but no answer was awaited (the user gets a heads-up, not a blocking prompt). Call `wait_for_answer` if you want to poll for a response anyway.
187
+ Always read `answered` rather than assuming the call blocked. It comes back false
188
+ in three different situations that mean different things: the wait timed out and
189
+ the question is still live (`timedOut`), the site policy is notify_only so nothing
190
+ was awaited (`status: "notified"`), or you passed `wait: false` yourself
191
+ (`status: "pending"`). All three leave a `correlationId` you can poll.
192
+
193
+ Pass `toolName` and `toolTarget` whenever the question is an approval for a tool
194
+ call. They are what let the user turn a repeated approval into an always-allow
195
+ rule, so an approval you label once is an approval they never see again.
235
196
 
236
197
  **Example - confirm (yes/no):**
237
198
 
@@ -270,28 +231,19 @@ Send a question to the user via push notification and wait for their answer. By
270
231
 
271
232
  ### wait_for_answer
272
233
 
273
- Poll for the user's response to a question sent via `ask_user` with `wait: false`. Not needed when using the default blocking mode.
274
-
275
- **Parameters:**
234
+ Poll for the user's response to a question sent via `ask_user` with `wait: false`, or to one that timed out. Not needed when using the default blocking mode.
276
235
 
277
- | Name | Type | Required | Description |
278
- |------|------|----------|-------------|
279
- | correlationId | string (uuid) | Yes | The correlationId from ask_user |
280
- | timeoutMs | integer | No | How long to wait (default 30000, max 55000) |
281
-
282
- **Returns:**
283
- - `{ "answered": true, "value": "yes" }` - user responded
284
- - `{ "answered": false }` - timeout reached, no answer yet
236
+ A single call waits at most 55 seconds but the question stays answerable for 10
237
+ minutes, so one empty return is not a refusal. Retry with the same
238
+ `correlationId` up to three times at `timeoutMs: 55000` before treating it as
239
+ unanswered.
285
240
 
286
241
  ### cancel_question
287
242
 
288
243
  Cancel a pending question so it can no longer be answered. Use when the question becomes irrelevant (e.g., you found the answer another way or the user responded in chat).
289
244
 
290
- **Parameters:**
291
-
292
- | Name | Type | Required | Description |
293
- |------|------|----------|-------------|
294
- | correlationId | string (uuid) | Yes | The correlationId of the question to cancel |
245
+ A stale approval arriving twenty minutes later is worse than no approval, because
246
+ it reads as consent to work that has already moved on.
295
247
 
296
248
  ### propose_scope
297
249
 
@@ -301,21 +253,13 @@ The user sees the paths you intend to change, the areas you promise to leave alo
301
253
 
302
254
  Use glob syntax (`src/**`, `**/*.test.ts`). Shell commands are **not** scoped here; they stay governed by the permission policy.
303
255
 
304
- **Parameters:**
256
+ `ratified` and `answered` are separate on purpose. Answered but not ratified means
257
+ the user declined: ask what scope they want, and do **not** proceed as if they had
258
+ agreed. Not answered means the scope is simply not in force.
305
259
 
306
- | Name | Type | Required | Description |
307
- |------|------|----------|-------------|
308
- | doneWhen | string | Yes | What "finished" means for this run. Carried for the human to judge against, never enforced automatically |
309
- | sessionId | string | Yes | Your per-session id. A scope with no session cannot be enforced and must never leak into another run |
310
- | allowedPaths | string[] | No | Globs you intend to change. Omit to propose no path restriction, which the user is told plainly |
311
- | offLimitsPaths | string[] | No | Globs you promise not to touch. These win wherever they overlap `allowedPaths` |
312
- | agentName | string | No | Name of the agent asking, format `"{Agent} - {project}"` |
313
- | timeoutMs | integer | No | How long this call blocks, max 55000 |
314
-
315
- **Returns:**
316
- - `{ "ratified": true, "answered": true, "value": "yes", "contract": {...} }` - the contract is live
317
- - `{ "ratified": false, "answered": true, "value": "no" }` - the user declined. Ask what scope they want; do **not** proceed as if they agreed
318
- - `{ "ratified": false, "answered": false }` - no answer yet. The scope is **not** in force
260
+ Omitting `allowedPaths` proposes no path restriction, and the user is told that
261
+ plainly as "this agent is asking to touch anything", so omit it only when you mean
262
+ it.
319
263
 
320
264
  **What enforcement depends on.** The contract is recorded and shown to the user by any MCP client. Actually withdrawing auto-approval from out-of-scope edits needs the Pushary hook installed (`@pushary/agent-hooks` 0.59.0 or later), which is how Claude Code, Codex and Gemini CLI run. Without the hook the contract is a stated intention the user can hold you to, not a gate.
321
265
 
@@ -327,15 +271,9 @@ Scope lives for the session only and is never inherited by another run.
327
271
 
328
272
  Read-only. Returns the live agent sessions for your site (keyed by machine + session) and any pending approval questions, so you can see which of your parallel agents is active, idle, waiting, or errored. Does NOT start, stop, or steer agents, and sends no notification. Useful when you are one of several agents and want to check whether another session is blocked on a question before acting.
329
273
 
330
- **Parameters:**
331
-
332
- | Name | Type | Required | Description |
333
- |------|------|----------|-------------|
334
- | status | "active" / "idle" / "waiting" / "errored" | No | Filter to sessions with this computed status |
335
- | limit | integer | No | Max number of sessions to return, most recently seen first (1-100) |
336
-
337
- **Returns:**
338
- - `{ "sessions": [...], "pendingQuestions": [...] }` - current sessions and open questions for the site
274
+ Check it before asking when you are one of several agents: if another session is
275
+ already blocked on a question, adding a second one competes for the same
276
+ attention rather than getting you answered sooner.
339
277
 
340
278
  ## Permission Gating (REQUIRED)
341
279
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pushary
3
- description: Push notifications and human-in-the-loop for AI agents. Use this whenever a running agent needs a human and nobody is at the terminal: before an irreversible or destructive action, before spending money, deploying, force-pushing or deleting, when blocked on a decision outside your authority, when running unattended and you hit a genuine ambiguity, when another skill's workflow says to confirm with the user, and when a long task finishes or fails with nobody watching. Also use it when the user says things like keep going and ping me on my phone if you need anything, notify me when my agent needs me, approve from my phone, ask me questions while I am away from the terminal, run this overnight, keep working while I am in a meeting, I am stepping away, do not wait for me, or wants a long task to run unattended. Send alerts when tasks finish or fail, ask questions (yes/no, multiple choice, or free text) via push, and get answers from the user's lock screen. Use these tools proactively - do not wait for the user to ask for notifications. Works with Claude Code, Codex, Cursor, VS Code, Windsurf, Hermes, Lovable, or any MCP client; no Claude Max subscription required. Pushary is a hosted service, $9.99/mo after a 3-day card-first trial.
3
+ description: Push notifications and human-in-the-loop for AI agents. Use this whenever a running agent needs a human and nobody is at the terminal: before an irreversible or destructive action, before spending money, deploying, force-pushing or deleting, when blocked on a decision outside your authority, when running unattended and you hit a genuine ambiguity, when another skill's workflow says to confirm with the user, and when a long task finishes or fails with nobody watching. Also use it when the user says things like keep going and ping me on my phone if you need anything, notify me when my agent needs me, approve from my phone, ask me questions while I am away from the terminal, run this overnight, keep working while I am in a meeting, I am stepping away, do not wait for me, or wants a long task to run unattended. Send alerts when tasks finish or fail, ask questions (yes/no, multiple choice, or free text) via push, and get answers from the user's lock screen. Use these tools proactively - do not wait for the user to ask for notifications. Every question and answer is recorded, so an unattended run stays reviewable afterwards. Works with Claude Code, Codex, Cursor, VS Code, Windsurf, Hermes, Lovable, or any MCP client; no Claude Max subscription required. Pushary is a hosted service, $9.99/mo after a 3-day card-first trial.
4
4
  ---
5
5
 
6
6
  # Pushary - Push Notifications for AI Agents
@@ -34,6 +34,19 @@ Pushary is a hosted service. It costs $9.99/mo after a 3-day trial, and the tria
34
34
 
35
35
  If the user runs Claude Code with a Claude Max subscription, Anthropic Remote Control is free for that one setup. Recommend it when that is all they need. Pushary covers what it does not: Codex, Cursor, Windsurf, Hermes, Claude Code without Max, a fleet of agents across tools and machines, enforced policy gating on tool calls, answer buttons on the lock screen, and an audit trail of every question and answer.
36
36
 
37
+ ## Plan the Questions Before You Start
38
+
39
+ Every question costs the user their attention wherever they happen to be. That cost is the only real limit on this tool, so spend it deliberately. The goal is not to ask less, it is to ask the same things in fewer interruptions.
40
+
41
+ Before a run of more than a step or two, work out where you will need a human, then fold those points together:
42
+
43
+ - **A fork you find while planning can be merged into one question.** A fork you find halfway through costs its own interruption. Finding them early is the whole saving.
44
+ - **One `select` carrying the real options beats three sequential `confirm`s.** Same information, a third of the interruptions.
45
+ - **Ask once at the boundary, not once per instance.** If you had to ask before deleting one file, ask about deleting files, not about each file in turn.
46
+ - **Never ask what you can determine.** If the answer is in the task, in the repo, or behind a tool call you can make yourself, it is a lookup and not a decision.
47
+
48
+ `propose_scope` is the strongest version of this: one approval at the start buys the whole run. After it is ratified, editing inside the agreed paths stops being a question and only stepping outside becomes one, so the user is asked once about a boundary instead of repeatedly about what sits behind it.
49
+
37
50
  ## When to Use
38
51
 
39
52
  **Send a notification when:**
@@ -112,54 +125,23 @@ npx @pushary/agent-hooks@latest doctor
112
125
 
113
126
  ## Tools
114
127
 
128
+ Every parameter and every returned field is described in each tool's own schema,
129
+ which your client already has and which is always current. What follows is only
130
+ what a schema cannot tell you: when to reach for a tool, what its result means for
131
+ what you do next, and the shapes that are easy to get wrong.
132
+
115
133
  ### send_notification
116
134
 
117
135
  Send a one-way push notification to the user. Optionally include structured context for a rich detail page.
118
136
 
119
- **Parameters:**
120
-
121
- | Name | Type | Required | Description |
122
- |------|------|----------|-------------|
123
- | title | string | Yes | Notification title (max 100 chars, aim for under 60) |
124
- | body | string | Yes | Notification body (max 500 chars, aim for under 200) |
125
- | url | string | No | URL opened when tapped. Ignored if context is provided. |
126
- | agentName | string | No | Identifies which agent sent this (e.g., "Claude Code - myproject") |
127
- | iconUrl | string | No | Custom notification icon URL |
128
- | imageUrl | string | No | Large image shown in the notification |
129
- | sessionId | string | No | Opaque per-session id of the sending agent, so parallel sessions are attributed separately (max 128 chars) |
130
- | machineId | string | No | Stable machine id of the sending agent, so two machines never collapse into one session (max 128 chars) |
131
- | subscriberIds | string[] | No | Target specific subscriber IDs |
132
- | externalIds | string[] | No | Target by external IDs |
133
- | tags | string[] | No | Target by subscriber tags |
134
- | context | object | Yes for task updates | Structured context for a rich detail page (see below). `context.type` marks the notification a task update, and the user's setting for where task updates land can only route one that says so. |
135
-
136
- **Context object:**
137
-
138
- | Name | Type | Description |
139
- |------|------|-------------|
140
- | type | "task_complete" / "error" / "info" | The kind of notification |
141
- | summary | string | Short summary of what happened |
142
- | details | string[] | Bullet-point details |
143
- | filesChanged | string[] | List of files that were changed |
144
- | errorMessage | string | Error message (for error type) |
145
- | errorFile | string | File path where the error occurred |
146
- | nextSteps | string | Suggested next steps for the user |
147
- | askQuestion | object | Embed a decision prompt in the notification (see below) |
148
-
149
- **Embedded askQuestion:**
150
-
151
- | Name | Type | Description |
152
- |------|------|-------------|
153
- | question | string | A follow-up question shown below the context |
154
- | type | "confirm" / "select" / "input" | Question type (default: confirm) |
155
- | options | string[] | Options for select type (2-6 items) |
156
-
157
- When `askQuestion` is provided, the response includes a `linkedCorrelationId` you pass to `wait_for_answer`.
158
-
159
- **Returns:**
160
- - `delivery` - per-channel result: `{ "web": { "recipients": <n> }, "mobile": { "recipients": <n> } }` (each channel may also include a `status` like `no_recipients` or `not_configured`)
161
- - `sent` - total devices reached across all channels
162
- - `warning` - present only when the notification reached 0 devices because no phone or browser is connected; the user must connect one in the dashboard under Settings then Connections
137
+ `context.type` is what marks a notification a **task update**, and the user's
138
+ setting for where task updates land can only route one that says so. A
139
+ notification sent without it reaches them wherever the default sends it.
140
+
141
+ On a long run where the user is likely away, prefer `context.askQuestion` over a
142
+ blocking `ask_user`. They get an ordinary push and answer whenever they next pick
143
+ up their phone, rather than you holding a 55-second wait open against someone who
144
+ is not there. Poll the returned `linkedCorrelationId` when you need the result.
163
145
 
164
146
  **Example - task completed with context:**
165
147
 
@@ -202,36 +184,15 @@ When `askQuestion` is provided, the response includes a `linkedCorrelationId` yo
202
184
 
203
185
  Send a question to the user via push notification and wait for their answer. By default, this tool **blocks** until the user responds or the timeout is reached - no need to call `wait_for_answer` separately.
204
186
 
205
- **Parameters:**
206
-
207
- | Name | Type | Required | Description |
208
- |------|------|----------|-------------|
209
- | question | string | Yes | The question to ask (max 500 chars) |
210
- | type | "confirm" / "select" / "input" | No | Question type (default: confirm) |
211
- | options | string[] | No | Choices for select type (2-6 options). Required when type is select. |
212
- | placeholder | string | No | Placeholder text for input type (max 200 chars) |
213
- | context | string | No | What the agent is working on, shown above the question (max 500 chars) |
214
- | wait | boolean | No | Wait for the answer before returning (default: true). Set false for manual polling. |
215
- | timeoutMs | integer | No | Max wait time in ms (max 55000). Uses site policy if omitted. |
216
- | agentName | string | No | Identifies which agent is asking. Format: "{Agent} - {project}" (e.g., "Claude Code - myproject") |
217
- | sessionId | string | No | Opaque per-session id of the asking agent, so parallel sessions are attributed separately (max 128 chars) |
218
- | machineId | string | No | Stable machine id of the asking agent, so two machines never collapse into one session (max 128 chars) |
219
- | toolName | string | No | The tool this approval is for (e.g. "Bash"), so the user can choose to always-allow it (max 100 chars) |
220
- | toolTarget | string | No | Compact target of the tool call (e.g. command head "git push" for Bash, or a file extension like ".ts" for Edit/Write). Used to mine always-allow policy suggestions (max 80 chars) |
221
- | callbackUrl | string | No | Webhook URL to POST the answer to when the user responds |
222
- | subscriberIds | string[] | No | Target specific subscriber IDs |
223
- | externalIds | string[] | No | Target by external IDs |
224
- | tags | string[] | No | Target by subscriber tags |
225
-
226
- **Returns (when wait=true, default):**
227
- - `{ "answered": true, "value": "yes", "correlationId": "uuid" }` - user responded
228
- - `{ "answered": false, "timedOut": true, "correlationId": "uuid" }` - timeout reached
229
-
230
- **Returns (when wait=false):**
231
- - `{ "correlationId": "uuid", "status": "pending", "expiresInSeconds": 600 }` - use `wait_for_answer` to poll
232
-
233
- **Returns (when the site policy is notify_only):**
234
- - `{ "correlationId": "uuid", "status": "notified", "answered": false, "mode": "notify_only" }` - the question was pushed but no answer was awaited (the user gets a heads-up, not a blocking prompt). Call `wait_for_answer` if you want to poll for a response anyway.
187
+ Always read `answered` rather than assuming the call blocked. It comes back false
188
+ in three different situations that mean different things: the wait timed out and
189
+ the question is still live (`timedOut`), the site policy is notify_only so nothing
190
+ was awaited (`status: "notified"`), or you passed `wait: false` yourself
191
+ (`status: "pending"`). All three leave a `correlationId` you can poll.
192
+
193
+ Pass `toolName` and `toolTarget` whenever the question is an approval for a tool
194
+ call. They are what let the user turn a repeated approval into an always-allow
195
+ rule, so an approval you label once is an approval they never see again.
235
196
 
236
197
  **Example - confirm (yes/no):**
237
198
 
@@ -270,28 +231,19 @@ Send a question to the user via push notification and wait for their answer. By
270
231
 
271
232
  ### wait_for_answer
272
233
 
273
- Poll for the user's response to a question sent via `ask_user` with `wait: false`. Not needed when using the default blocking mode.
274
-
275
- **Parameters:**
234
+ Poll for the user's response to a question sent via `ask_user` with `wait: false`, or to one that timed out. Not needed when using the default blocking mode.
276
235
 
277
- | Name | Type | Required | Description |
278
- |------|------|----------|-------------|
279
- | correlationId | string (uuid) | Yes | The correlationId from ask_user |
280
- | timeoutMs | integer | No | How long to wait (default 30000, max 55000) |
281
-
282
- **Returns:**
283
- - `{ "answered": true, "value": "yes" }` - user responded
284
- - `{ "answered": false }` - timeout reached, no answer yet
236
+ A single call waits at most 55 seconds but the question stays answerable for 10
237
+ minutes, so one empty return is not a refusal. Retry with the same
238
+ `correlationId` up to three times at `timeoutMs: 55000` before treating it as
239
+ unanswered.
285
240
 
286
241
  ### cancel_question
287
242
 
288
243
  Cancel a pending question so it can no longer be answered. Use when the question becomes irrelevant (e.g., you found the answer another way or the user responded in chat).
289
244
 
290
- **Parameters:**
291
-
292
- | Name | Type | Required | Description |
293
- |------|------|----------|-------------|
294
- | correlationId | string (uuid) | Yes | The correlationId of the question to cancel |
245
+ A stale approval arriving twenty minutes later is worse than no approval, because
246
+ it reads as consent to work that has already moved on.
295
247
 
296
248
  ### propose_scope
297
249
 
@@ -301,21 +253,13 @@ The user sees the paths you intend to change, the areas you promise to leave alo
301
253
 
302
254
  Use glob syntax (`src/**`, `**/*.test.ts`). Shell commands are **not** scoped here; they stay governed by the permission policy.
303
255
 
304
- **Parameters:**
256
+ `ratified` and `answered` are separate on purpose. Answered but not ratified means
257
+ the user declined: ask what scope they want, and do **not** proceed as if they had
258
+ agreed. Not answered means the scope is simply not in force.
305
259
 
306
- | Name | Type | Required | Description |
307
- |------|------|----------|-------------|
308
- | doneWhen | string | Yes | What "finished" means for this run. Carried for the human to judge against, never enforced automatically |
309
- | sessionId | string | Yes | Your per-session id. A scope with no session cannot be enforced and must never leak into another run |
310
- | allowedPaths | string[] | No | Globs you intend to change. Omit to propose no path restriction, which the user is told plainly |
311
- | offLimitsPaths | string[] | No | Globs you promise not to touch. These win wherever they overlap `allowedPaths` |
312
- | agentName | string | No | Name of the agent asking, format `"{Agent} - {project}"` |
313
- | timeoutMs | integer | No | How long this call blocks, max 55000 |
314
-
315
- **Returns:**
316
- - `{ "ratified": true, "answered": true, "value": "yes", "contract": {...} }` - the contract is live
317
- - `{ "ratified": false, "answered": true, "value": "no" }` - the user declined. Ask what scope they want; do **not** proceed as if they agreed
318
- - `{ "ratified": false, "answered": false }` - no answer yet. The scope is **not** in force
260
+ Omitting `allowedPaths` proposes no path restriction, and the user is told that
261
+ plainly as "this agent is asking to touch anything", so omit it only when you mean
262
+ it.
319
263
 
320
264
  **What enforcement depends on.** The contract is recorded and shown to the user by any MCP client. Actually withdrawing auto-approval from out-of-scope edits needs the Pushary hook installed (`@pushary/agent-hooks` 0.59.0 or later), which is how Claude Code, Codex and Gemini CLI run. Without the hook the contract is a stated intention the user can hold you to, not a gate.
321
265
 
@@ -327,15 +271,9 @@ Scope lives for the session only and is never inherited by another run.
327
271
 
328
272
  Read-only. Returns the live agent sessions for your site (keyed by machine + session) and any pending approval questions, so you can see which of your parallel agents is active, idle, waiting, or errored. Does NOT start, stop, or steer agents, and sends no notification. Useful when you are one of several agents and want to check whether another session is blocked on a question before acting.
329
273
 
330
- **Parameters:**
331
-
332
- | Name | Type | Required | Description |
333
- |------|------|----------|-------------|
334
- | status | "active" / "idle" / "waiting" / "errored" | No | Filter to sessions with this computed status |
335
- | limit | integer | No | Max number of sessions to return, most recently seen first (1-100) |
336
-
337
- **Returns:**
338
- - `{ "sessions": [...], "pendingQuestions": [...] }` - current sessions and open questions for the site
274
+ Check it before asking when you are one of several agents: if another session is
275
+ already blocked on a question, adding a second one competes for the same
276
+ attention rather than getting you answered sooner.
339
277
 
340
278
  ## Permission Gating (REQUIRED)
341
279
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleBell
4
- } from "../chunk-22EQOB2T.js";
4
+ } from "../chunk-DNFYUBRP.js";
5
5
  import "../chunk-E6I3DBJW.js";
6
6
  import "../chunk-6MTNS63X.js";
7
7
  import "../chunk-BC3VCZ3E.js";
@@ -5,7 +5,7 @@ import {
5
5
  liveAgentCount,
6
6
  ring,
7
7
  upgradeThreshold
8
- } from "../chunk-22EQOB2T.js";
8
+ } from "../chunk-DNFYUBRP.js";
9
9
  import {
10
10
  claudeSettings
11
11
  } from "../chunk-Q4QULGAV.js";
@@ -3,7 +3,7 @@ import {
3
3
  KILL_REASON,
4
4
  isDeferAnswer,
5
5
  resolveGate
6
- } from "../chunk-A5DAEWBZ.js";
6
+ } from "../chunk-PVSCRRPA.js";
7
7
  import {
8
8
  askUser,
9
9
  cancelQuestion,
@@ -15,7 +15,7 @@ import {
15
15
  reportEvent,
16
16
  scopePathFor,
17
17
  waitForAnswer
18
- } from "../chunk-DUYM5SHH.js";
18
+ } from "../chunk-FVVJ3FOW.js";
19
19
  import "../chunk-BSZYIAZL.js";
20
20
  import "../chunk-SAF6HGAA.js";
21
21
  import "../chunk-PPD3HHNJ.js";
@@ -8,7 +8,7 @@ import {
8
8
  denyReasonFrom,
9
9
  isDeferAnswer,
10
10
  resolveGate
11
- } from "../chunk-A5DAEWBZ.js";
11
+ } from "../chunk-PVSCRRPA.js";
12
12
  import {
13
13
  CODEX_AGENT,
14
14
  DEFAULT_SESSION,
@@ -39,7 +39,7 @@ import {
39
39
  toPolicyLookup,
40
40
  toPolicyLookups,
41
41
  waitForAnswer
42
- } from "../chunk-DUYM5SHH.js";
42
+ } from "../chunk-FVVJ3FOW.js";
43
43
  import "../chunk-BSZYIAZL.js";
44
44
  import "../chunk-SAF6HGAA.js";
45
45
  import "../chunk-PPD3HHNJ.js";
@@ -3,7 +3,7 @@ import {
3
3
  askUser,
4
4
  reportEvent,
5
5
  waitForAnswer
6
- } from "../chunk-DUYM5SHH.js";
6
+ } from "../chunk-FVVJ3FOW.js";
7
7
  import "../chunk-BSZYIAZL.js";
8
8
  import "../chunk-SAF6HGAA.js";
9
9
  import "../chunk-PPD3HHNJ.js";
@@ -8,7 +8,7 @@ import {
8
8
  denyReasonFrom,
9
9
  isDeferAnswer,
10
10
  resolveGate
11
- } from "../chunk-A5DAEWBZ.js";
11
+ } from "../chunk-PVSCRRPA.js";
12
12
  import {
13
13
  DEFAULT_SESSION,
14
14
  askUser,
@@ -30,7 +30,7 @@ import {
30
30
  scopePathFor,
31
31
  sendNotification,
32
32
  waitForAnswer
33
- } from "../chunk-DUYM5SHH.js";
33
+ } from "../chunk-FVVJ3FOW.js";
34
34
  import "../chunk-BSZYIAZL.js";
35
35
  import "../chunk-SAF6HGAA.js";
36
36
  import "../chunk-PPD3HHNJ.js";
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePreToolUse
4
- } from "../chunk-ULITFWLG.js";
4
+ } from "../chunk-JU72KNUB.js";
5
5
  import "../chunk-R6AYBATA.js";
6
6
  import "../chunk-MHA2WB7S.js";
7
- import "../chunk-A5DAEWBZ.js";
8
- import "../chunk-DUYM5SHH.js";
7
+ import "../chunk-PVSCRRPA.js";
8
+ import "../chunk-FVVJ3FOW.js";
9
9
  import "../chunk-BSZYIAZL.js";
10
10
  import "../chunk-SAF6HGAA.js";
11
11
  import "../chunk-PPD3HHNJ.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleNotification
4
- } from "../chunk-DUYM5SHH.js";
4
+ } from "../chunk-FVVJ3FOW.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-PPD3HHNJ.js";
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePermissionDenied
4
- } from "../chunk-ULITFWLG.js";
4
+ } from "../chunk-JU72KNUB.js";
5
5
  import "../chunk-R6AYBATA.js";
6
6
  import "../chunk-MHA2WB7S.js";
7
- import "../chunk-A5DAEWBZ.js";
8
- import "../chunk-DUYM5SHH.js";
7
+ import "../chunk-PVSCRRPA.js";
8
+ import "../chunk-FVVJ3FOW.js";
9
9
  import "../chunk-BSZYIAZL.js";
10
10
  import "../chunk-SAF6HGAA.js";
11
11
  import "../chunk-PPD3HHNJ.js";
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePermissionRequest
4
- } from "../chunk-ULITFWLG.js";
4
+ } from "../chunk-JU72KNUB.js";
5
5
  import "../chunk-R6AYBATA.js";
6
6
  import "../chunk-MHA2WB7S.js";
7
- import "../chunk-A5DAEWBZ.js";
8
- import "../chunk-DUYM5SHH.js";
7
+ import "../chunk-PVSCRRPA.js";
8
+ import "../chunk-FVVJ3FOW.js";
9
9
  import "../chunk-BSZYIAZL.js";
10
10
  import "../chunk-SAF6HGAA.js";
11
11
  import "../chunk-PPD3HHNJ.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePostToolUse
4
- } from "../chunk-DUYM5SHH.js";
4
+ } from "../chunk-FVVJ3FOW.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-PPD3HHNJ.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleUserPrompt
4
- } from "../chunk-DUYM5SHH.js";
4
+ } from "../chunk-FVVJ3FOW.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-PPD3HHNJ.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleSessionEnd
4
- } from "../chunk-DUYM5SHH.js";
4
+ } from "../chunk-FVVJ3FOW.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-PPD3HHNJ.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleSessionStart
4
- } from "../chunk-DUYM5SHH.js";
4
+ } from "../chunk-FVVJ3FOW.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-PPD3HHNJ.js";
@@ -49,7 +49,7 @@ import {
49
49
  } from "../chunk-Q4QULGAV.js";
50
50
  import {
51
51
  reportEvent
52
- } from "../chunk-DUYM5SHH.js";
52
+ } from "../chunk-FVVJ3FOW.js";
53
53
  import {
54
54
  confirmAppConnection,
55
55
  connectDevice,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleStop
4
- } from "../chunk-DUYM5SHH.js";
4
+ } from "../chunk-FVVJ3FOW.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-PPD3HHNJ.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleStopFailure
4
- } from "../chunk-DUYM5SHH.js";
4
+ } from "../chunk-FVVJ3FOW.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-PPD3HHNJ.js";
@@ -3,20 +3,37 @@ import {
3
3
  } from "./chunk-E6I3DBJW.js";
4
4
 
5
5
  // src/bell/fleet.ts
6
- import { mkdirSync, readdirSync, statSync, utimesSync, writeFileSync, unlinkSync } from "fs";
6
+ import { closeSync, constants, futimesSync, lstatSync, mkdirSync, openSync, readdirSync, unlinkSync } from "fs";
7
7
  import { createHash } from "crypto";
8
- import { tmpdir } from "os";
8
+ import { tmpdir, userInfo } from "os";
9
9
  import { join } from "path";
10
- var BELL_DIR = join(tmpdir(), "pushary-bell");
10
+ var ownerTag = () => {
11
+ try {
12
+ const { uid } = userInfo();
13
+ return typeof uid === "number" && uid >= 0 ? String(uid) : "shared";
14
+ } catch {
15
+ return "shared";
16
+ }
17
+ };
18
+ var BELL_DIR = join(tmpdir(), `pushary-bell-${ownerTag()}`);
19
+ var DIR_MODE = 448;
20
+ var FILE_MODE = 384;
11
21
  var SESSION_TTL_MS = 10 * 60 * 1e3;
12
22
  var sessionFile = (sessionId) => join(BELL_DIR, createHash("sha256").update(sessionId).digest("hex").slice(0, 16));
13
23
  var heartbeat = (sessionId, now = Date.now()) => {
14
24
  try {
15
- mkdirSync(BELL_DIR, { recursive: true });
16
- const path = sessionFile(sessionId);
17
- writeFileSync(path, "", "utf-8");
18
- const seconds = now / 1e3;
19
- utimesSync(path, seconds, seconds);
25
+ mkdirSync(BELL_DIR, { recursive: true, mode: DIR_MODE });
26
+ const fd = openSync(
27
+ sessionFile(sessionId),
28
+ constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW,
29
+ FILE_MODE
30
+ );
31
+ try {
32
+ const seconds = now / 1e3;
33
+ futimesSync(fd, seconds, seconds);
34
+ } finally {
35
+ closeSync(fd);
36
+ }
20
37
  } catch {
21
38
  }
22
39
  };
@@ -26,7 +43,9 @@ var liveAgentCount = (now = Date.now()) => {
26
43
  for (const name of readdirSync(BELL_DIR)) {
27
44
  const path = join(BELL_DIR, name);
28
45
  try {
29
- if (now - statSync(path).mtimeMs < SESSION_TTL_MS) live += 1;
46
+ const entry = lstatSync(path);
47
+ if (!entry.isFile()) unlinkSync(path);
48
+ else if (now - entry.mtimeMs < SESSION_TTL_MS) live += 1;
30
49
  else unlinkSync(path);
31
50
  } catch {
32
51
  }
@@ -43,16 +62,16 @@ var recordAndCount = (sessionId, now = Date.now()) => {
43
62
 
44
63
  // src/bell/ring.ts
45
64
  import { execFile } from "child_process";
46
- import { openSync, writeSync, closeSync } from "fs";
65
+ import { openSync as openSync2, writeSync, closeSync as closeSync2 } from "fs";
47
66
  var platformOf = (value = process.platform) => value === "darwin" || value === "linux" || value === "win32" ? value : "other";
48
67
  var writeBell = () => {
49
68
  try {
50
- const fd = openSync("/dev/tty", "w");
69
+ const fd = openSync2("/dev/tty", "w");
51
70
  try {
52
71
  writeSync(fd, "\x07");
53
72
  return true;
54
73
  } finally {
55
- closeSync(fd);
74
+ closeSync2(fd);
56
75
  }
57
76
  } catch {
58
77
  try {
@@ -96,7 +115,7 @@ var ring = (notice, deps = {}) => {
96
115
  };
97
116
 
98
117
  // src/bell/upgrade.ts
99
- import { existsSync, mkdirSync as mkdirSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
118
+ import { existsSync, mkdirSync as mkdirSync2, statSync, writeFileSync } from "fs";
100
119
  import { tmpdir as tmpdir2 } from "os";
101
120
  import { join as join2 } from "path";
102
121
  var DEFAULT_UPGRADE_THRESHOLD = 3;
@@ -109,7 +128,7 @@ var upgradeThreshold = (env = process.env) => {
109
128
  };
110
129
  var readLastShown = () => {
111
130
  try {
112
- return existsSync(UPGRADE_MARKER) ? statSync2(UPGRADE_MARKER).mtimeMs : null;
131
+ return existsSync(UPGRADE_MARKER) ? statSync(UPGRADE_MARKER).mtimeMs : null;
113
132
  } catch {
114
133
  return null;
115
134
  }
@@ -117,7 +136,7 @@ var readLastShown = () => {
117
136
  var writeLastShown = (at) => {
118
137
  try {
119
138
  mkdirSync2(tmpdir2(), { recursive: true });
120
- writeFileSync2(UPGRADE_MARKER, String(at), "utf-8");
139
+ writeFileSync(UPGRADE_MARKER, String(at), "utf-8");
121
140
  } catch {
122
141
  }
123
142
  };
@@ -1264,7 +1264,10 @@ var handleNotification = async (input, agent = CLAUDE_CODE_AGENT) => {
1264
1264
  title: `${agent.label} is waiting`,
1265
1265
  body: (input.message ?? "Your agent is waiting for you.").slice(0, 200),
1266
1266
  agentName: `${agent.label} - ${projectName}`,
1267
- sessionId: input.session_id
1267
+ sessionId: input.session_id,
1268
+ // Makes this an `update` server-side, so resolveSiteMode gates it on
1269
+ // the wildcard policy too. `info` because nothing finished.
1270
+ context: { type: "info" }
1268
1271
  }, 4e3).catch(() => {
1269
1272
  });
1270
1273
  return;
@@ -9,7 +9,7 @@ import {
9
9
  denyReasonFrom,
10
10
  isDeferAnswer,
11
11
  resolveGate
12
- } from "./chunk-A5DAEWBZ.js";
12
+ } from "./chunk-PVSCRRPA.js";
13
13
  import {
14
14
  DEFAULT_SESSION,
15
15
  askUser,
@@ -29,7 +29,7 @@ import {
29
29
  sendNotification,
30
30
  throttlePass,
31
31
  waitForAnswer
32
- } from "./chunk-DUYM5SHH.js";
32
+ } from "./chunk-FVVJ3FOW.js";
33
33
  import {
34
34
  buildDecisionEpisodeFeatures,
35
35
  effectiveWaitSeconds,
@@ -4,7 +4,7 @@ import {
4
4
  isModeStateDegraded,
5
5
  resolveAutoResolveOrigin,
6
6
  resolvePolicyAcross
7
- } from "./chunk-DUYM5SHH.js";
7
+ } from "./chunk-FVVJ3FOW.js";
8
8
  import {
9
9
  evaluateScope,
10
10
  scopeChangeReason,
package/dist/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  handlePreToolUse
3
- } from "../chunk-ULITFWLG.js";
3
+ } from "../chunk-JU72KNUB.js";
4
4
  import "../chunk-R6AYBATA.js";
5
5
  import "../chunk-MHA2WB7S.js";
6
- import "../chunk-A5DAEWBZ.js";
6
+ import "../chunk-PVSCRRPA.js";
7
7
  import {
8
8
  askUser,
9
9
  cancelQuestion,
@@ -16,7 +16,7 @@ import {
16
16
  reportEvent,
17
17
  resolvePolicy,
18
18
  waitForAnswer
19
- } from "../chunk-DUYM5SHH.js";
19
+ } from "../chunk-FVVJ3FOW.js";
20
20
  import "../chunk-BSZYIAZL.js";
21
21
  import "../chunk-SAF6HGAA.js";
22
22
  import "../chunk-PPD3HHNJ.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushary/agent-hooks",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "description": "Permission hooks for AI coding agents: route tool approvals through Pushary push notifications",
5
5
  "keywords": [
6
6
  "pushary",