@pushary/agent-hooks 0.76.1 → 0.78.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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.78.0
4
+
5
+ ### Your agent can ask you several things at once, and reach your phone for all of them
6
+
7
+ An agent that stops to ask you something often asks more than one thing in the
8
+ same breath. Until this release the hook only recognised a single question, so
9
+ two or more meant nothing arrived on your phone at all and the whole exchange
10
+ waited at the terminal. That is exactly the moment this product exists for.
11
+
12
+ Up to four questions now come through in turn. The notification says which one
13
+ you are on, "(2 of 3)", so the first does not look like the last.
14
+
15
+ If any question goes unanswered, whether you deferred it, it timed out, or no
16
+ device was reachable, the entire exchange goes back to the terminal, including
17
+ anything you already answered. A partly filled answer sheet would leave your
18
+ agent guessing at questions it never put to you, and being asked twice is better
19
+ than being answered wrongly.
20
+
21
+ The whole exchange shares one wait budget. Four questions do not cost four times
22
+ the wait you agreed to.
23
+
24
+ Questions that let you tick several options at once still go to the terminal.
25
+ Those answers travel as a single piece of text and the packing is not something
26
+ this release can verify, so it is left alone rather than guessed at.
27
+
28
+ ## 0.77.0
29
+
30
+ ### Stop ends the process
31
+
32
+ Until this release Stop was a gate verdict. Every pending and new tool call was
33
+ denied, which is the guarantee and has not changed, but the agent kept running,
34
+ kept thinking and kept spending tokens. For someone who hits Stop because
35
+ something is going wrong, "it can no longer act" and "it stopped" are different
36
+ promises.
37
+
38
+ - every session event reports its own pid, so the server can name the process
39
+ - the daemon advertises a `stop` capability, which is how the server knows a
40
+ machine can act on one at all
41
+ - the daemon applies stops from the drain with SIGTERM, and refuses to signal
42
+ its own pid, since killing itself would take every other session's stop too
43
+
44
+ Two additive wire changes, both safe in either direction: `pid` on the event
45
+ payload, which an older server ignores, and `stops` on the drain response, which
46
+ an older daemon ignores.
47
+
48
+ ## 0.76.1
49
+
50
+ ### Actually closing the spawn argv injection
51
+
52
+ 0.76.0 shipped under the message "closing the spawn argv injection" and did not.
53
+ It sanitised `model` and `cwd` but not `prompt`, and `prompt` is the field the
54
+ daemon passes as the token immediately after `-p`, so a flag-shaped prompt was
55
+ read back as a flag:
56
+
57
+ ['claude','--remote','-p','--permission-mode=dontAsk'] -> {permissionMode:'dontAsk'}
58
+
59
+ `dontAsk` is a full-defer mode, so the gate stopped asking about every tool for
60
+ that session.
61
+
62
+ The server-side rejection landed in production first and is what actually
63
+ protects users, 0.76.0 included, with no upgrade required. This release carries
64
+ the defence in depth: `buildSpawnLaunch` returns null for a flag-shaped prompt,
65
+ so a daemon never builds that argv even if the server is bypassed.
66
+
3
67
  ## 0.76.0
4
68
 
5
69
  ### A phone-supplied field could switch your approval gate off
@@ -7,7 +7,7 @@ import {
7
7
  KILL_REASON,
8
8
  isDeferAnswer,
9
9
  resolveGate
10
- } from "../chunk-UPPHYOIZ.js";
10
+ } from "../chunk-FWWBEB6L.js";
11
11
  import {
12
12
  askUser,
13
13
  cancelQuestion,
@@ -19,7 +19,7 @@ import {
19
19
  reportEvent,
20
20
  scopePathFor,
21
21
  waitForAnswer
22
- } from "../chunk-7L57N7WN.js";
22
+ } from "../chunk-HPDLDQC5.js";
23
23
  import "../chunk-BSZYIAZL.js";
24
24
  import "../chunk-SAF6HGAA.js";
25
25
  import "../chunk-7QLSKOSU.js";
@@ -4,7 +4,7 @@ import {
4
4
  denyReasonFrom,
5
5
  isDeferAnswer,
6
6
  resolveGate
7
- } from "../chunk-UPPHYOIZ.js";
7
+ } from "../chunk-FWWBEB6L.js";
8
8
  import {
9
9
  CODEX_AGENT,
10
10
  DEFAULT_SESSION,
@@ -35,7 +35,7 @@ import {
35
35
  toPolicyLookup,
36
36
  toPolicyLookups,
37
37
  waitForAnswer
38
- } from "../chunk-7L57N7WN.js";
38
+ } from "../chunk-HPDLDQC5.js";
39
39
  import {
40
40
  isGatingMoment,
41
41
  recordKeylessMoment
@@ -3,7 +3,7 @@ import {
3
3
  askUser,
4
4
  reportEvent,
5
5
  waitForAnswer
6
- } from "../chunk-7L57N7WN.js";
6
+ } from "../chunk-HPDLDQC5.js";
7
7
  import "../chunk-BSZYIAZL.js";
8
8
  import "../chunk-SAF6HGAA.js";
9
9
  import "../chunk-7QLSKOSU.js";
@@ -28,6 +28,27 @@ import {
28
28
  import { spawn } from "child_process";
29
29
  import { existsSync } from "fs";
30
30
  import { basename, dirname, join } from "path";
31
+
32
+ // src/stop-requests.ts
33
+ var STOP_CAPABILITY = "stop";
34
+ var isStopRequest = (value) => {
35
+ if (!value || typeof value !== "object") return false;
36
+ const candidate = value;
37
+ return typeof candidate.sessionId === "string" && candidate.sessionId.length > 0 && typeof candidate.pid === "number" && Number.isInteger(candidate.pid) && candidate.pid > 1;
38
+ };
39
+ var parseStopRequests = (value) => Array.isArray(value) ? value.filter(isStopRequest) : [];
40
+ var applyStop = (request, deps) => {
41
+ if (request.pid === deps.selfPid) return "skipped_self";
42
+ try {
43
+ deps.kill(request.pid, "SIGTERM");
44
+ return "stopped";
45
+ } catch (err) {
46
+ const code = err?.code;
47
+ return code === "ESRCH" ? "already_gone" : "refused";
48
+ }
49
+ };
50
+
51
+ // src/spawn-daemon.ts
31
52
  var DRAIN_PATH = "/api/agent/spawn/drain";
32
53
  var FAST_POLL_MS = 3e3;
33
54
  var SLOW_POLL_MS = 3e4;
@@ -121,6 +142,17 @@ var runSpawnDaemon = async () => {
121
142
  `);
122
143
  }
123
144
  };
145
+ const runStop = (stop) => {
146
+ const outcome = applyStop(stop, { kill: process.kill.bind(process), selfPid: process.pid });
147
+ const said = {
148
+ stopped: `stopped session ${stop.sessionId}`,
149
+ already_gone: `session ${stop.sessionId} had already exited`,
150
+ refused: `could not stop session ${stop.sessionId}, the system refused the signal`,
151
+ skipped_self: `refused to stop session ${stop.sessionId}: that pid is this daemon`
152
+ };
153
+ stderr(`[pushary] ${said[outcome]}
154
+ `);
155
+ };
124
156
  const drain = async (signal) => {
125
157
  const res = await fetch(`${baseUrl}${DRAIN_PATH}`, {
126
158
  method: "POST",
@@ -130,6 +162,7 @@ var runSpawnDaemon = async () => {
130
162
  });
131
163
  if (!res.ok) throw new DrainError(res.status);
132
164
  const data = await res.json();
165
+ for (const stop of parseStopRequests(data.stops)) runStop(stop);
133
166
  const spawn2 = data.spawn;
134
167
  return spawn2 && typeof spawn2.prompt === "string" && typeof spawn2.id === "string" ? spawn2 : null;
135
168
  };
@@ -182,7 +215,10 @@ var runSpawnDaemon = async () => {
182
215
  };
183
216
  const beat = async () => {
184
217
  if (stopped) return;
185
- const result = await sendHeartbeat(apiKey, { repoKey: basename(process.cwd()) });
218
+ const result = await sendHeartbeat(apiKey, {
219
+ repoKey: basename(process.cwd()),
220
+ capabilities: ["spawn", STOP_CAPABILITY]
221
+ });
186
222
  if (result.kind === "unauthorized" && !stopped) {
187
223
  fatalCode = EXIT.UNAUTHENTICATED;
188
224
  stderr("[pushary] daemon stopping: this machine's key was rejected. Run `pushary login` to sign in again.\n");
@@ -4,7 +4,7 @@ import {
4
4
  denyReasonFrom,
5
5
  isDeferAnswer,
6
6
  resolveGate
7
- } from "../chunk-UPPHYOIZ.js";
7
+ } from "../chunk-FWWBEB6L.js";
8
8
  import {
9
9
  DEFAULT_SESSION,
10
10
  askUser,
@@ -26,7 +26,7 @@ import {
26
26
  scopePathFor,
27
27
  sendNotification,
28
28
  waitForAnswer
29
- } from "../chunk-7L57N7WN.js";
29
+ } from "../chunk-HPDLDQC5.js";
30
30
  import {
31
31
  isGatingMoment,
32
32
  recordKeylessMoment
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePreToolUse
4
- } from "../chunk-DJHWBNM3.js";
4
+ } from "../chunk-NN4GTN6Q.js";
5
5
  import "../chunk-3CBPY2G5.js";
6
6
  import {
7
7
  exitOnHelpFlag
8
8
  } from "../chunk-BBK3TTU2.js";
9
9
  import "../chunk-7OYGFJYZ.js";
10
- import "../chunk-UPPHYOIZ.js";
11
- import "../chunk-7L57N7WN.js";
10
+ import "../chunk-FWWBEB6L.js";
11
+ import "../chunk-HPDLDQC5.js";
12
12
  import "../chunk-IA5GBKNL.js";
13
13
  import "../chunk-BSZYIAZL.js";
14
14
  import "../chunk-SAF6HGAA.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleNotification
4
- } from "../chunk-7L57N7WN.js";
4
+ } from "../chunk-HPDLDQC5.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-7QLSKOSU.js";
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePermissionDenied
4
- } from "../chunk-DJHWBNM3.js";
4
+ } from "../chunk-NN4GTN6Q.js";
5
5
  import "../chunk-3CBPY2G5.js";
6
- import "../chunk-UPPHYOIZ.js";
7
- import "../chunk-7L57N7WN.js";
6
+ import "../chunk-FWWBEB6L.js";
7
+ import "../chunk-HPDLDQC5.js";
8
8
  import "../chunk-IA5GBKNL.js";
9
9
  import "../chunk-BSZYIAZL.js";
10
10
  import "../chunk-SAF6HGAA.js";
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePermissionRequest
4
- } from "../chunk-DJHWBNM3.js";
4
+ } from "../chunk-NN4GTN6Q.js";
5
5
  import "../chunk-3CBPY2G5.js";
6
- import "../chunk-UPPHYOIZ.js";
7
- import "../chunk-7L57N7WN.js";
6
+ import "../chunk-FWWBEB6L.js";
7
+ import "../chunk-HPDLDQC5.js";
8
8
  import "../chunk-IA5GBKNL.js";
9
9
  import "../chunk-BSZYIAZL.js";
10
10
  import "../chunk-SAF6HGAA.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePostToolUse
4
- } from "../chunk-7L57N7WN.js";
4
+ } from "../chunk-HPDLDQC5.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-7QLSKOSU.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleUserPrompt
4
- } from "../chunk-7L57N7WN.js";
4
+ } from "../chunk-HPDLDQC5.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-7QLSKOSU.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleSessionEnd
4
- } from "../chunk-7L57N7WN.js";
4
+ } from "../chunk-HPDLDQC5.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-7QLSKOSU.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleSessionStart
4
- } from "../chunk-7L57N7WN.js";
4
+ } from "../chunk-HPDLDQC5.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-7QLSKOSU.js";
@@ -94,7 +94,7 @@ import {
94
94
  } from "../chunk-7OYGFJYZ.js";
95
95
  import {
96
96
  reportEvent
97
- } from "../chunk-7L57N7WN.js";
97
+ } from "../chunk-HPDLDQC5.js";
98
98
  import "../chunk-BSZYIAZL.js";
99
99
  import "../chunk-SAF6HGAA.js";
100
100
  import {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleStop
4
- } from "../chunk-7L57N7WN.js";
4
+ } from "../chunk-HPDLDQC5.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-7QLSKOSU.js";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleStopFailure
4
- } from "../chunk-7L57N7WN.js";
4
+ } from "../chunk-HPDLDQC5.js";
5
5
  import "../chunk-BSZYIAZL.js";
6
6
  import "../chunk-SAF6HGAA.js";
7
7
  import "../chunk-7QLSKOSU.js";
@@ -4,7 +4,7 @@ import {
4
4
  isModeStateDegraded,
5
5
  resolveAutoResolveOrigin,
6
6
  resolvePolicyAcross
7
- } from "./chunk-7L57N7WN.js";
7
+ } from "./chunk-HPDLDQC5.js";
8
8
  import {
9
9
  evaluateScope,
10
10
  scopeChangeReason,
@@ -1028,6 +1028,9 @@ var reportEvent = async (event, options = {}) => {
1028
1028
  body: JSON.stringify({
1029
1029
  ...event,
1030
1030
  machineId: event.machineId ?? getMachineId(),
1031
+ // Lets the phone's Stop end this process instead of only denying its next
1032
+ // tool call. Ours alone: never another process, never a parent.
1033
+ pid: event.pid ?? process.pid,
1031
1034
  repoKey: event.repoKey ?? repoKeyFor(),
1032
1035
  installMode: detectInstallMode(),
1033
1036
  // Link-back for a phone-spawned session: forward the spawn id the daemon
@@ -5,7 +5,7 @@ import {
5
5
  denyReasonFrom,
6
6
  isDeferAnswer,
7
7
  resolveGate
8
- } from "./chunk-UPPHYOIZ.js";
8
+ } from "./chunk-FWWBEB6L.js";
9
9
  import {
10
10
  DEFAULT_SESSION,
11
11
  askUser,
@@ -25,7 +25,7 @@ import {
25
25
  sendNotification,
26
26
  throttlePass,
27
27
  waitForAnswer
28
- } from "./chunk-7L57N7WN.js";
28
+ } from "./chunk-HPDLDQC5.js";
29
29
  import {
30
30
  isGatingMoment,
31
31
  recordKeylessMoment
@@ -345,47 +345,66 @@ var shouldDeferToNativeMode = (permissionMode, toolName) => {
345
345
  }
346
346
  return false;
347
347
  };
348
- var parseSingleQuestion = (toolInput) => {
349
- const questions = toolInput.questions;
350
- if (!Array.isArray(questions) || questions.length !== 1) return void 0;
351
- const q = questions[0];
348
+ var MAX_PHONE_QUESTIONS = 4;
349
+ var parseQuestion = (raw) => {
350
+ const q = raw;
352
351
  if (!q || typeof q.question !== "string" || q.multiSelect === true) return void 0;
353
352
  const labels = Array.isArray(q.options) ? q.options.map((o) => o && typeof o === "object" ? o.label : void 0).filter((l) => typeof l === "string" && l.length > 0) : [];
354
353
  if (labels.length < 2) return void 0;
355
354
  return { question: q.question, header: typeof q.header === "string" ? q.header : void 0, labels };
356
355
  };
356
+ var parseQuestions = (toolInput) => {
357
+ const questions = toolInput.questions;
358
+ if (!Array.isArray(questions) || questions.length === 0) return void 0;
359
+ if (questions.length > MAX_PHONE_QUESTIONS) return void 0;
360
+ const parsed = [];
361
+ for (const raw of questions) {
362
+ const one = parseQuestion(raw);
363
+ if (!one) return void 0;
364
+ parsed.push(one);
365
+ }
366
+ return parsed;
367
+ };
368
+ var questionContext = (q, index, total, project) => {
369
+ const subject = q.header ? `${q.header}: your agent is asking in ${project}` : `Your agent is asking in ${project}`;
370
+ return total > 1 ? `${subject} (${index + 1} of ${total})` : subject;
371
+ };
357
372
  var handleAskUserQuestion = async (apiKey, input) => {
358
- const parsed = parseSingleQuestion(input.tool_input ?? {});
373
+ const parsed = parseQuestions(input.tool_input ?? {});
359
374
  if (!parsed) return void 0;
360
375
  const projectName = basename(input.cwd ?? process.cwd());
361
- let result;
362
- try {
363
- result = await askUser(apiKey, {
364
- question: parsed.question,
365
- type: "select",
366
- options: [...parsed.labels],
367
- context: parsed.header ? `${parsed.header}: your agent is asking in ${projectName}` : `Your agent is asking in ${projectName}`,
368
- agentName: `Claude Code - ${projectName}`,
369
- sessionId: input.session_id,
370
- machineId: getMachineId(),
371
- toolName: "AskUserQuestion"
372
- });
373
- } catch {
374
- return void 0;
375
- }
376
- if (result.suppressed || result.noDevices) {
377
- await cancelQuestion(apiKey, result.correlationId).catch(() => {
378
- });
379
- return void 0;
380
- }
381
376
  const deadline = hookWaitDeadline(START_MS, effectiveWaitSeconds("wait", 0, "claude"), "claude", Date.now());
382
- const answer = await pollForAnswer(apiKey, result.correlationId, deadline);
383
- const value = answer.answered ? (answer.value ?? "").trim() : "";
384
- if (!value || isDeferAnswer(answer.value)) {
385
- savePendingQuestion(input.session_id || DEFAULT_SESSION, result.correlationId);
386
- return void 0;
377
+ const answers = {};
378
+ for (const [index, question] of parsed.entries()) {
379
+ let result;
380
+ try {
381
+ result = await askUser(apiKey, {
382
+ question: question.question,
383
+ type: "select",
384
+ options: [...question.labels],
385
+ context: questionContext(question, index, parsed.length, projectName),
386
+ agentName: `Claude Code - ${projectName}`,
387
+ sessionId: input.session_id,
388
+ machineId: getMachineId(),
389
+ toolName: "AskUserQuestion"
390
+ });
391
+ } catch {
392
+ return void 0;
393
+ }
394
+ if (result.suppressed || result.noDevices) {
395
+ await cancelQuestion(apiKey, result.correlationId).catch(() => {
396
+ });
397
+ return void 0;
398
+ }
399
+ const answer = await pollForAnswer(apiKey, result.correlationId, deadline);
400
+ const value = answer.answered ? (answer.value ?? "").trim() : "";
401
+ if (!value || isDeferAnswer(answer.value)) {
402
+ savePendingQuestion(input.session_id || DEFAULT_SESSION, result.correlationId);
403
+ return void 0;
404
+ }
405
+ answers[question.question] = value;
387
406
  }
388
- return allowWithInput({ ...input.tool_input ?? {}, answers: { [parsed.question]: value } });
407
+ return allowWithInput({ ...input.tool_input ?? {}, answers });
389
408
  };
390
409
  var handleKeyless = (input) => {
391
410
  try {
@@ -70,6 +70,7 @@ interface AgentEvent {
70
70
  agents?: readonly string[];
71
71
  meta?: ReceiptMeta;
72
72
  usage?: UsageReport;
73
+ pid?: number;
73
74
  }
74
75
  interface ReportEventOptions {
75
76
  maxAttempts?: number;
package/dist/src/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  handlePreToolUse
3
- } from "../chunk-DJHWBNM3.js";
3
+ } from "../chunk-NN4GTN6Q.js";
4
4
  import "../chunk-3CBPY2G5.js";
5
- import "../chunk-UPPHYOIZ.js";
5
+ import "../chunk-FWWBEB6L.js";
6
6
  import {
7
7
  askUser,
8
8
  cancelQuestion,
@@ -15,7 +15,7 @@ import {
15
15
  reportEvent,
16
16
  resolvePolicy,
17
17
  waitForAnswer
18
- } from "../chunk-7L57N7WN.js";
18
+ } from "../chunk-HPDLDQC5.js";
19
19
  import "../chunk-IA5GBKNL.js";
20
20
  import "../chunk-BSZYIAZL.js";
21
21
  import "../chunk-SAF6HGAA.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushary/agent-hooks",
3
- "version": "0.76.1",
3
+ "version": "0.78.0",
4
4
  "description": "Permission hooks for AI coding agents: route tool approvals through Pushary push notifications",
5
5
  "keywords": [
6
6
  "pushary",