@pushary/agent-hooks 0.5.1 → 0.6.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.
@@ -10,6 +10,7 @@ import { join } from "path";
10
10
  import { homedir } from "os";
11
11
  import { execSync } from "child_process";
12
12
  import { confirm } from "@inquirer/prompts";
13
+ import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
13
14
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
14
15
  var bold = (s) => `\x1B[1m${s}\x1B[0m`;
15
16
  var green = (s) => `\x1B[32m${s}\x1B[0m`;
@@ -21,7 +22,7 @@ var CLAUDE_SETTINGS_LOCAL = join(homedir(), ".claude", "settings.local.json");
21
22
  var CLAUDE_JSON = join(homedir(), ".claude.json");
22
23
  var SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
23
24
  var CURSOR_MCP = join(".cursor", "mcp.json");
24
- var SHELL_FILES = [".zshrc", ".bashrc"].map((f) => join(homedir(), f));
25
+ var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
25
26
  var readJson = (path) => {
26
27
  try {
27
28
  return JSON.parse(readFileSync(path, "utf-8"));
@@ -91,26 +92,19 @@ var main = async () => {
91
92
  }
92
93
  const codexConfig = join(homedir(), ".codex", "config.toml");
93
94
  try {
94
- let config = readFileSync(codexConfig, "utf-8");
95
+ const config = readFileSync(codexConfig, "utf-8");
95
96
  const hadPushary = config.includes("pushary");
96
97
  if (hadPushary) {
97
- const lines = config.split("\n");
98
- const cleaned = [];
99
- let skipping = false;
100
- for (const line of lines) {
101
- if (/^\[mcp_servers\.pushary(?:\.|]$)/.test(line.trim())) {
102
- skipping = true;
103
- continue;
104
- }
105
- if (skipping && line.startsWith("[") && !/^\[mcp_servers\.pushary/.test(line.trim())) {
106
- skipping = false;
107
- }
108
- if (skipping) continue;
109
- if (line.includes("pushary-codex")) continue;
110
- cleaned.push(line);
98
+ const parsed = parseTOML(config);
99
+ const mcpServers = parsed.mcp_servers ?? {};
100
+ delete mcpServers.pushary;
101
+ if (Object.keys(mcpServers).length === 0) delete parsed.mcp_servers;
102
+ else parsed.mcp_servers = mcpServers;
103
+ if (Array.isArray(parsed.notify)) {
104
+ parsed.notify = parsed.notify.filter((n) => typeof n !== "string" || !n.includes("pushary-codex"));
105
+ if (parsed.notify.length === 0) delete parsed.notify;
111
106
  }
112
- config = cleaned.join("\n").replace(/\n{3,}/g, "\n\n");
113
- writeFileSync(codexConfig, config, "utf-8");
107
+ writeFileSync(codexConfig, stringifyTOML(parsed), "utf-8");
114
108
  console.log(` ${check} Codex config ${dim("(cleaned)")}`);
115
109
  } else {
116
110
  console.log(` ${skip} Codex config ${dim("(no pushary entries)")}`);
@@ -130,7 +124,7 @@ var main = async () => {
130
124
  }
131
125
  }
132
126
  try {
133
- execSync("npm uninstall -g @pushary/agent-hooks", { stdio: "ignore" });
127
+ execSync("npm uninstall -g @pushary/agent-hooks", { stdio: "ignore", timeout: 3e4 });
134
128
  console.log(` ${check} Global package ${dim("(uninstalled)")}`);
135
129
  } catch {
136
130
  console.log(` ${skip} Global package ${dim("(not installed)")}`);
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  reportEvent
4
- } from "../chunk-P4JH2Q7Z.js";
4
+ } from "../chunk-KYARP7KP.js";
5
5
  import {
6
6
  askUser,
7
7
  waitForAnswer
8
- } from "../chunk-KTP2EPVB.js";
8
+ } from "../chunk-4Z4MB37G.js";
9
9
  import {
10
10
  getApiKey
11
- } from "../chunk-VIST7ACL.js";
11
+ } from "../chunk-O6A5RHWY.js";
12
12
 
13
13
  // bin/pushary-codex.ts
14
14
  import { hostname } from "os";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  callMcpTool,
4
4
  sendMcpRequest
5
- } from "../chunk-VIST7ACL.js";
5
+ } from "../chunk-O6A5RHWY.js";
6
6
 
7
7
  // bin/pushary-doctor.ts
8
8
  import { existsSync, readFileSync } from "fs";
@@ -89,7 +89,7 @@ var main = async () => {
89
89
  let globalVersion = "";
90
90
  try {
91
91
  const { execSync } = await import("child_process");
92
- globalVersion = execSync("npm list -g @pushary/agent-hooks --depth=0 2>/dev/null", { encoding: "utf-8" }).match(/@pushary\/agent-hooks@([\d.]+)/)?.[1] ?? "";
92
+ globalVersion = execSync("npm list -g @pushary/agent-hooks --depth=0 2>/dev/null", { encoding: "utf-8", timeout: 1e4 }).match(/@pushary\/agent-hooks@([\d.]+)/)?.[1] ?? "";
93
93
  } catch {
94
94
  }
95
95
  check(!!globalVersion, "Global package installed", globalVersion || "not found");
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePreToolUse
4
- } from "../chunk-DF3BM6BF.js";
5
- import "../chunk-KTP2EPVB.js";
6
- import "../chunk-VIST7ACL.js";
4
+ } from "../chunk-WNXGIEX7.js";
5
+ import "../chunk-4Z4MB37G.js";
6
+ import "../chunk-O6A5RHWY.js";
7
7
 
8
8
  // bin/pushary-hook.ts
9
9
  var main = async () => {
@@ -25,14 +25,19 @@ var main = async () => {
25
25
  }
26
26
  try {
27
27
  const output = await handlePreToolUse(input);
28
- if (output) {
29
- process.stdout.write(JSON.stringify(output));
30
- }
28
+ process.stdout.write(JSON.stringify(output));
31
29
  } catch (err) {
32
30
  process.stderr.write(
33
31
  `[pushary-hook] Error: ${err instanceof Error ? err.message : String(err)}
34
32
  `
35
33
  );
34
+ process.stdout.write(JSON.stringify({
35
+ hookSpecificOutput: {
36
+ hookEventName: "PreToolUse",
37
+ permissionDecision: "ask",
38
+ permissionDecisionReason: "Pushary hook error, falling back to terminal"
39
+ }
40
+ }));
36
41
  }
37
42
  };
38
43
  main();
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handlePostToolUse
4
- } from "../chunk-P4JH2Q7Z.js";
5
- import "../chunk-KTP2EPVB.js";
6
- import "../chunk-VIST7ACL.js";
4
+ } from "../chunk-KYARP7KP.js";
5
+ import "../chunk-4Z4MB37G.js";
6
+ import "../chunk-O6A5RHWY.js";
7
7
 
8
8
  // bin/pushary-post-hook.ts
9
9
  var main = async () => {
@@ -12,13 +12,14 @@ import { homedir } from "os";
12
12
  import { execSync } from "child_process";
13
13
  import { checkbox, input, confirm } from "@inquirer/prompts";
14
14
  import { fileURLToPath } from "url";
15
+ import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
15
16
  var CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
16
17
  var CLAUDE_JSON = join(homedir(), ".claude.json");
17
18
  var CURSOR_MCP = join(".cursor", "mcp.json");
18
19
  var CURSOR_RULES_DIR = join(".cursor", "rules");
19
20
  var CLAUDE_SKILL_DIR = join(homedir(), ".claude", "skills", "pushary");
20
21
  var CODEX_SKILL_DIR = join(homedir(), ".codex", "skills", "pushary");
21
- var SHELL_FILES = [".zshrc", ".bashrc"].map((f) => join(homedir(), f));
22
+ var SHELL_FILES = [".zshrc", ".zprofile", ".bashrc", ".bash_profile"].map((f) => join(homedir(), f));
22
23
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
23
24
  var bold = (s) => `\x1B[1m${s}\x1B[0m`;
24
25
  var green = (s) => `\x1B[32m${s}\x1B[0m`;
@@ -37,7 +38,16 @@ var writeJson = (path, data) => {
37
38
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
38
39
  writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
39
40
  };
40
- var formatError = (err) => err instanceof Error ? err.message : String(err);
41
+ var formatError = (err) => {
42
+ if (err instanceof Error) {
43
+ const errWithSignal = err;
44
+ if (errWithSignal.killed && errWithSignal.signal === "SIGTERM") {
45
+ return "timed out \u2014 check your network connection";
46
+ }
47
+ return err.message;
48
+ }
49
+ return String(err);
50
+ };
41
51
  var spinner = async (label, fn, options = {}) => {
42
52
  const frames = [" ", ". ", ".. ", "..."];
43
53
  let i = 0;
@@ -82,7 +92,7 @@ var checkForUpdates = async (current) => {
82
92
  };
83
93
  var installGlobally = async () => {
84
94
  await spinner("Installing pushary-hook globally", async () => {
85
- execSync("npm install -g @pushary/agent-hooks@latest", { stdio: "ignore" });
95
+ execSync("npm install -g @pushary/agent-hooks@latest", { stdio: "ignore", timeout: 12e4 });
86
96
  });
87
97
  };
88
98
  var _cachedSkillContent = null;
@@ -150,7 +160,7 @@ var findPython310Plus = () => {
150
160
  const candidates = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python"];
151
161
  for (const py of candidates) {
152
162
  try {
153
- const version = execSync(`${py} --version 2>&1`, { encoding: "utf-8", stdio: "pipe" }).trim();
163
+ const version = execSync(`${py} --version 2>&1`, { encoding: "utf-8", stdio: "pipe", timeout: 5e3 }).trim();
154
164
  const match = version.match(/Python (\d+)\.(\d+)/);
155
165
  if (match && (Number(match[1]) > 3 || Number(match[1]) === 3 && Number(match[2]) >= 10)) {
156
166
  return py;
@@ -161,7 +171,7 @@ var findPython310Plus = () => {
161
171
  return null;
162
172
  };
163
173
  var installPythonPlugin = (pythonBin) => {
164
- execSync(`${pythonBin} -m pip install --upgrade hermes-plugin-pushary`, { stdio: "pipe" });
174
+ execSync(`${pythonBin} -m pip install --upgrade hermes-plugin-pushary`, { stdio: "pipe", timeout: 12e4 });
165
175
  };
166
176
  var setupHermes = async (_apiKey) => {
167
177
  console.log(`
@@ -170,7 +180,7 @@ var setupHermes = async (_apiKey) => {
170
180
  const whichCmd = process.platform === "win32" ? "where" : "which";
171
181
  const hasHermes = (() => {
172
182
  try {
173
- execSync(`${whichCmd} hermes`, { stdio: "ignore" });
183
+ execSync(`${whichCmd} hermes`, { stdio: "ignore", timeout: 5e3 });
174
184
  return true;
175
185
  } catch {
176
186
  return false;
@@ -183,7 +193,7 @@ var setupHermes = async (_apiKey) => {
183
193
  }
184
194
  await spinner("Installing hermes-plugin-pushary", async () => {
185
195
  try {
186
- execSync("uv pip install hermes-plugin-pushary", { stdio: "pipe" });
196
+ execSync("uv pip install hermes-plugin-pushary", { stdio: "pipe", timeout: 12e4 });
187
197
  return;
188
198
  } catch {
189
199
  }
@@ -191,8 +201,8 @@ var setupHermes = async (_apiKey) => {
191
201
  if (!python) {
192
202
  if (process.platform === "darwin") {
193
203
  try {
194
- execSync("which brew", { stdio: "ignore" });
195
- execSync("brew install python@3.12", { stdio: "pipe" });
204
+ execSync("which brew", { stdio: "ignore", timeout: 5e3 });
205
+ execSync("brew install python@3.12", { stdio: "pipe", timeout: 3e5 });
196
206
  python = findPython310Plus();
197
207
  } catch {
198
208
  }
@@ -204,8 +214,8 @@ var setupHermes = async (_apiKey) => {
204
214
  ["which pacman", "sudo pacman -S --noconfirm python python-pip"]
205
215
  ]) {
206
216
  try {
207
- execSync(check2, { stdio: "ignore" });
208
- execSync(install, { stdio: "pipe" });
217
+ execSync(check2, { stdio: "ignore", timeout: 5e3 });
218
+ execSync(install, { stdio: "pipe", timeout: 3e5 });
209
219
  python = findPython310Plus();
210
220
  if (python) break;
211
221
  } catch {
@@ -219,7 +229,7 @@ var setupHermes = async (_apiKey) => {
219
229
  }
220
230
  for (const pip of ["pip3", "pip"]) {
221
231
  try {
222
- execSync(`${pip} install hermes-plugin-pushary`, { stdio: "pipe" });
232
+ execSync(`${pip} install hermes-plugin-pushary`, { stdio: "pipe", timeout: 12e4 });
223
233
  return;
224
234
  } catch {
225
235
  }
@@ -227,7 +237,7 @@ var setupHermes = async (_apiKey) => {
227
237
  throw new Error("Python 3.10+ not found and could not be installed");
228
238
  });
229
239
  await spinner("Enabling plugin", async () => {
230
- execSync("hermes plugins enable pushary", { stdio: "ignore" });
240
+ execSync("hermes plugins enable pushary", { stdio: "ignore", timeout: 1e4 });
231
241
  });
232
242
  console.log();
233
243
  console.log(` ${dim("What this configured:")}`);
@@ -241,7 +251,7 @@ var setupCodex = async (_apiKey) => {
241
251
  const whichCmd = process.platform === "win32" ? "where" : "which";
242
252
  const hasCodex = (() => {
243
253
  try {
244
- execSync(`${whichCmd} codex`, { stdio: "ignore" });
254
+ execSync(`${whichCmd} codex`, { stdio: "ignore", timeout: 5e3 });
245
255
  return true;
246
256
  } catch {
247
257
  return false;
@@ -258,61 +268,41 @@ var setupCodex = async (_apiKey) => {
258
268
  try {
259
269
  execSync(
260
270
  "codex mcp add pushary --url https://pushary.com/api/mcp/mcp --bearer-token-env-var PUSHARY_API_KEY",
261
- { stdio: "ignore" }
271
+ { stdio: "ignore", timeout: 15e3 }
262
272
  );
263
273
  } catch {
264
274
  }
265
275
  });
266
276
  await spinner("Auto-allowing all Pushary tools", async () => {
267
- let config = "";
277
+ let raw = "";
268
278
  try {
269
- config = readFileSync(codexConfig, "utf-8");
279
+ raw = readFileSync(codexConfig, "utf-8");
270
280
  } catch {
271
281
  }
272
- const lines = config.split("\n");
273
- const cleaned = [];
274
- let skippingToolSection = false;
275
- for (const line of lines) {
276
- if (/^\[mcp_servers\.pushary\.tools\./.test(line.trim())) {
277
- skippingToolSection = true;
278
- continue;
279
- }
280
- if (skippingToolSection) {
281
- if (line.startsWith("[") || line.trim() === "") {
282
- skippingToolSection = false;
283
- if (line.startsWith("[")) {
284
- cleaned.push(line);
285
- continue;
286
- }
287
- } else {
288
- continue;
289
- }
290
- }
291
- cleaned.push(line);
292
- }
293
- config = cleaned.join("\n");
294
- if (!config.includes("default_tools_approval_mode")) {
295
- config = config.replace(
296
- /(\[mcp_servers\.pushary\]\n)/,
297
- '$1default_tools_approval_mode = "approve"\n'
298
- );
299
- }
300
- writeFileSync(codexConfig, config, "utf-8");
282
+ const config = raw ? parseTOML(raw) : {};
283
+ const mcpServers = config.mcp_servers ?? {};
284
+ const pushary = mcpServers.pushary ?? {};
285
+ pushary.default_tools_approval_mode = "approve";
286
+ delete pushary.tools;
287
+ mcpServers.pushary = pushary;
288
+ config.mcp_servers = mcpServers;
289
+ writeFileSync(codexConfig, stringifyTOML(config), "utf-8");
301
290
  });
302
291
  await spinner("Adding notify handler for Codex events", async () => {
303
- const globalPrefix = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
292
+ const globalPrefix = execSync("npm prefix -g", { encoding: "utf-8", timeout: 5e3 }).trim();
304
293
  const pusharyCodexPath = join(globalPrefix, "bin", "pushary-codex");
305
294
  if (!existsSync(pusharyCodexPath)) throw new Error("pushary-codex not found at " + pusharyCodexPath);
306
- let config = "";
295
+ let raw = "";
307
296
  try {
308
- config = readFileSync(codexConfig, "utf-8");
297
+ raw = readFileSync(codexConfig, "utf-8");
309
298
  } catch {
310
299
  }
311
- if (!config.includes("pushary-codex")) {
312
- const notifyLine = `
313
- notify = ["${pusharyCodexPath}"]
314
- `;
315
- appendFileSync(codexConfig, notifyLine, "utf-8");
300
+ const config = raw ? parseTOML(raw) : {};
301
+ const notify = Array.isArray(config.notify) ? config.notify : [];
302
+ if (!notify.some((n) => typeof n === "string" && n.includes("pushary-codex"))) {
303
+ notify.push(pusharyCodexPath);
304
+ config.notify = notify;
305
+ writeFileSync(codexConfig, stringifyTOML(config), "utf-8");
316
306
  }
317
307
  });
318
308
  await installSkillToDir(CODEX_SKILL_DIR, "Installing Pushary skill");
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleStop
4
- } from "../chunk-P4JH2Q7Z.js";
5
- import "../chunk-KTP2EPVB.js";
6
- import "../chunk-VIST7ACL.js";
4
+ } from "../chunk-KYARP7KP.js";
5
+ import "../chunk-4Z4MB37G.js";
6
+ import "../chunk-O6A5RHWY.js";
7
7
 
8
8
  // bin/pushary-stop-hook.ts
9
9
  var main = async () => {
@@ -0,0 +1,96 @@
1
+ import {
2
+ callMcpTool
3
+ } from "./chunk-O6A5RHWY.js";
4
+
5
+ // src/validate.ts
6
+ var isPolicyConfig = (data) => {
7
+ if (!data || typeof data !== "object") return false;
8
+ const d = data;
9
+ return Array.isArray(d.policies) && typeof d.defaultTimeoutSeconds === "number" && typeof d.defaultTimeoutAction === "string";
10
+ };
11
+ var isAskUserResponse = (data) => {
12
+ if (!data || typeof data !== "object") return false;
13
+ const d = data;
14
+ return typeof d.correlationId === "string" && typeof d.status === "string";
15
+ };
16
+ var isWaitForAnswerResponse = (data) => {
17
+ if (!data || typeof data !== "object") return false;
18
+ const d = data;
19
+ return typeof d.answered === "boolean";
20
+ };
21
+
22
+ // src/api.ts
23
+ var askUser = async (apiKey, params) => {
24
+ const result = await callMcpTool(apiKey, "ask_user", { ...params });
25
+ if (!isAskUserResponse(result)) throw new Error("Invalid ask_user response");
26
+ return result;
27
+ };
28
+ var waitForAnswer = async (apiKey, correlationId, timeoutMs = 3e4) => {
29
+ const result = await callMcpTool(apiKey, "wait_for_answer", {
30
+ correlationId,
31
+ timeoutMs
32
+ });
33
+ if (!isWaitForAnswerResponse(result)) throw new Error("Invalid wait_for_answer response");
34
+ return result;
35
+ };
36
+ var cancelQuestion = async (apiKey, correlationId) => {
37
+ await callMcpTool(apiKey, "cancel_question", { correlationId });
38
+ };
39
+ var sendNotification = async (apiKey, params) => {
40
+ await callMcpTool(apiKey, "send_notification", { ...params });
41
+ };
42
+
43
+ // src/describe.ts
44
+ var hookPrefixes = {
45
+ Bash: (input) => `bash: ${input.command ?? "(no command)"}`,
46
+ Write: (input) => `write file: ${input.file_path ?? "(unknown path)"}`,
47
+ Edit: (input) => `edit file: ${input.file_path ?? "(unknown path)"}`,
48
+ Read: (input) => `read file: ${input.file_path ?? "(unknown path)"}`
49
+ };
50
+ var eventPrefixes = {
51
+ Bash: (input) => `ran: ${String(input.command ?? "").slice(0, 120)}`,
52
+ Write: (input) => `wrote: ${input.file_path ?? "unknown"}`,
53
+ Edit: (input) => `edited: ${input.file_path ?? "unknown"}`,
54
+ Read: (input) => `read: ${input.file_path ?? "unknown"}`
55
+ };
56
+ var describeToolCall = (toolName, toolInput, format = "hook") => {
57
+ const prefixes = format === "hook" ? hookPrefixes : eventPrefixes;
58
+ const builder = prefixes[toolName];
59
+ if (builder) return builder(toolInput);
60
+ return format === "hook" ? `${toolName}: ${JSON.stringify(toolInput).slice(0, 200)}` : `${toolName}: done`;
61
+ };
62
+
63
+ // src/pending.ts
64
+ import { join } from "path";
65
+ import { tmpdir } from "os";
66
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from "fs";
67
+ var PENDING_DIR = join(tmpdir(), "pushary-pending");
68
+ var savePendingQuestion = (correlationId) => {
69
+ if (!existsSync(PENDING_DIR)) mkdirSync(PENDING_DIR, { recursive: true });
70
+ writeFileSync(join(PENDING_DIR, correlationId), "", "utf-8");
71
+ };
72
+ var listPendingQuestions = () => {
73
+ try {
74
+ return readdirSync(PENDING_DIR);
75
+ } catch {
76
+ return [];
77
+ }
78
+ };
79
+ var removePendingQuestion = (correlationId) => {
80
+ try {
81
+ unlinkSync(join(PENDING_DIR, correlationId));
82
+ } catch {
83
+ }
84
+ };
85
+
86
+ export {
87
+ isPolicyConfig,
88
+ askUser,
89
+ waitForAnswer,
90
+ cancelQuestion,
91
+ sendNotification,
92
+ describeToolCall,
93
+ savePendingQuestion,
94
+ listPendingQuestions,
95
+ removePendingQuestion
96
+ };
@@ -0,0 +1,97 @@
1
+ import {
2
+ cancelQuestion,
3
+ describeToolCall,
4
+ listPendingQuestions,
5
+ removePendingQuestion
6
+ } from "./chunk-4Z4MB37G.js";
7
+ import {
8
+ getApiKey,
9
+ getBaseUrl,
10
+ withRetry
11
+ } from "./chunk-O6A5RHWY.js";
12
+
13
+ // src/events.ts
14
+ import { hostname } from "os";
15
+ import { basename } from "path";
16
+ var cleanupPendingQuestions = async () => {
17
+ try {
18
+ const files = listPendingQuestions();
19
+ const apiKey = getApiKey();
20
+ for (const correlationId of files) {
21
+ try {
22
+ await cancelQuestion(apiKey, correlationId);
23
+ } catch {
24
+ }
25
+ removePendingQuestion(correlationId);
26
+ }
27
+ } catch {
28
+ }
29
+ };
30
+ var reportEvent = async (event) => {
31
+ const apiKey = getApiKey();
32
+ const baseUrl = getBaseUrl();
33
+ await withRetry(async () => {
34
+ await fetch(`${baseUrl}/api/agent/event`, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ "Authorization": `Bearer ${apiKey}`
39
+ },
40
+ body: JSON.stringify({
41
+ ...event,
42
+ machineId: event.machineId ?? hostname()
43
+ }),
44
+ signal: AbortSignal.timeout(1e4)
45
+ });
46
+ }, { maxAttempts: 2, baseDelayMs: 300 });
47
+ };
48
+ var handlePostToolUse = async (input) => {
49
+ try {
50
+ const projectName = basename(input.cwd ?? process.cwd());
51
+ const action = describeToolCall(input.tool_name, input.tool_input, "event");
52
+ const isError = input.tool_result && ("error" in input.tool_result || "is_error" in input.tool_result);
53
+ await Promise.allSettled([
54
+ cleanupPendingQuestions(),
55
+ reportEvent({
56
+ event: isError ? "tool_error" : "tool_complete",
57
+ agentType: "claude_code",
58
+ agentName: `Claude Code - ${projectName}`,
59
+ action,
60
+ error: isError ? String(input.tool_result?.error ?? input.tool_result?.stderr ?? "").slice(0, 500) : void 0
61
+ })
62
+ ]);
63
+ } catch {
64
+ }
65
+ };
66
+ var handleStop = async (input) => {
67
+ try {
68
+ const projectName = basename(input.cwd ?? process.cwd());
69
+ await reportEvent({
70
+ event: "session_end",
71
+ agentType: "claude_code",
72
+ agentName: `Claude Code - ${projectName}`,
73
+ action: "Session ended"
74
+ });
75
+ } catch {
76
+ }
77
+ };
78
+ var handleNotification = async (input) => {
79
+ try {
80
+ const projectName = basename(input.cwd ?? process.cwd());
81
+ await reportEvent({
82
+ event: input.type === "error" ? "error" : "notification",
83
+ agentType: "claude_code",
84
+ agentName: `Claude Code - ${projectName}`,
85
+ action: input.title ?? input.message ?? "Notification",
86
+ error: input.type === "error" ? input.message : void 0
87
+ });
88
+ } catch {
89
+ }
90
+ };
91
+
92
+ export {
93
+ reportEvent,
94
+ handlePostToolUse,
95
+ handleStop,
96
+ handleNotification
97
+ };
@@ -0,0 +1,122 @@
1
+ // src/config.ts
2
+ var getApiKey = () => {
3
+ const key = process.env.PUSHARY_API_KEY;
4
+ if (!key) {
5
+ throw new Error(
6
+ "PUSHARY_API_KEY environment variable is not set. Get your API key at https://pushary.com/sign-up?from=ai-coding"
7
+ );
8
+ }
9
+ return key;
10
+ };
11
+ var getBaseUrl = () => process.env.PUSHARY_BASE_URL ?? "https://pushary.com";
12
+
13
+ // src/retry.ts
14
+ var isRetryable = (err) => {
15
+ if (!(err instanceof Error)) return false;
16
+ const msg = err.message;
17
+ return /\b(502|503|429)\b/.test(msg) || /ECONNRESET|ECONNREFUSED|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|fetch failed/i.test(msg) || msg.includes("AbortError");
18
+ };
19
+ var withRetry = async (fn, options = {}) => {
20
+ const {
21
+ maxAttempts = 3,
22
+ baseDelayMs = 500,
23
+ maxDelayMs = 5e3,
24
+ shouldRetry = isRetryable
25
+ } = options;
26
+ let lastError;
27
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
28
+ try {
29
+ return await fn();
30
+ } catch (err) {
31
+ lastError = err;
32
+ if (attempt + 1 >= maxAttempts || !shouldRetry(err)) throw err;
33
+ const jitter = Math.random() * 0.3 + 0.85;
34
+ const delay = Math.min(baseDelayMs * 2 ** attempt * jitter, maxDelayMs);
35
+ await new Promise((r) => setTimeout(r, delay));
36
+ }
37
+ }
38
+ throw lastError;
39
+ };
40
+
41
+ // src/mcp-http.ts
42
+ var parseSseJson = (body) => {
43
+ const messages = [];
44
+ for (const event of body.split(/\r?\n\r?\n/)) {
45
+ const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n").trim();
46
+ if (!data) continue;
47
+ try {
48
+ messages.push(JSON.parse(data));
49
+ } catch {
50
+ }
51
+ }
52
+ const message = messages.at(-1);
53
+ if (!message) throw new Error("Empty response from Pushary");
54
+ return message;
55
+ };
56
+ var parseMcpResponse = (body, contentType) => {
57
+ if (contentType?.includes("text/event-stream")) {
58
+ return parseSseJson(body);
59
+ }
60
+ return JSON.parse(body);
61
+ };
62
+ var sendMcpRequest = async (apiKey, message, options = {}) => {
63
+ return withRetry(async () => {
64
+ const baseUrl = options.baseUrl ?? getBaseUrl();
65
+ const fetchFn = options.fetchFn ?? fetch;
66
+ const headers = {
67
+ "Content-Type": "application/json",
68
+ "Accept": "application/json, text/event-stream",
69
+ "Authorization": `Bearer ${apiKey}`
70
+ };
71
+ if (options.sessionId) {
72
+ headers["Mcp-Session-Id"] = options.sessionId;
73
+ }
74
+ const response = await fetchFn(`${baseUrl}/api/mcp/mcp`, {
75
+ method: "POST",
76
+ headers,
77
+ body: JSON.stringify(message),
78
+ signal: options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : void 0
79
+ });
80
+ const body = await response.text();
81
+ const contentType = response.headers.get("content-type");
82
+ let data = null;
83
+ if (body.trim()) {
84
+ try {
85
+ data = parseMcpResponse(body, contentType);
86
+ } catch (err) {
87
+ if (response.ok) throw err;
88
+ }
89
+ }
90
+ if (!response.ok) {
91
+ const msg = data?.error?.message ?? (body.trim() || response.statusText);
92
+ throw new Error(`Pushary MCP error: ${response.status} ${msg}`);
93
+ }
94
+ if (!data) throw new Error("Empty response from Pushary");
95
+ if (data.error) throw new Error(data.error.message ?? "Pushary MCP error");
96
+ return {
97
+ data,
98
+ sessionId: response.headers.get("mcp-session-id") ?? "",
99
+ status: response.status,
100
+ statusText: response.statusText
101
+ };
102
+ }, { maxAttempts: options.maxRetries ?? 1 });
103
+ };
104
+ var callMcpTool = async (apiKey, toolName, params, options = {}) => {
105
+ const { data } = await sendMcpRequest(apiKey, {
106
+ jsonrpc: "2.0",
107
+ id: options.id ?? Date.now(),
108
+ method: "tools/call",
109
+ params: { name: toolName, arguments: params }
110
+ }, options);
111
+ const text = data.result?.content?.[0]?.text;
112
+ if (!text) throw new Error("Empty response from Pushary");
113
+ return JSON.parse(text);
114
+ };
115
+
116
+ export {
117
+ getApiKey,
118
+ getBaseUrl,
119
+ withRetry,
120
+ sendMcpRequest,
121
+ callMcpTool
122
+ };
@@ -0,0 +1,219 @@
1
+ import {
2
+ askUser,
3
+ describeToolCall,
4
+ isPolicyConfig,
5
+ savePendingQuestion,
6
+ sendNotification,
7
+ waitForAnswer
8
+ } from "./chunk-4Z4MB37G.js";
9
+ import {
10
+ getApiKey,
11
+ getBaseUrl,
12
+ withRetry
13
+ } from "./chunk-O6A5RHWY.js";
14
+
15
+ // src/policy.ts
16
+ import { createHash } from "crypto";
17
+ import { existsSync, readFileSync, writeFileSync } from "fs";
18
+ import { join } from "path";
19
+ import { tmpdir } from "os";
20
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
21
+ var cacheFile = (apiKey) => {
22
+ const hash = createHash("sha256").update(apiKey).digest("hex").slice(0, 12);
23
+ return join(tmpdir(), `pushary-policy-${hash}.json`);
24
+ };
25
+ var fetchPolicy = async (apiKey) => {
26
+ return withRetry(async () => {
27
+ const baseUrl = getBaseUrl();
28
+ const response = await fetch(`${baseUrl}/api/mcp/policy`, {
29
+ headers: { "Authorization": `Bearer ${apiKey}` },
30
+ signal: AbortSignal.timeout(1e4)
31
+ });
32
+ if (!response.ok) {
33
+ throw new Error(`Failed to fetch policy: ${response.status}`);
34
+ }
35
+ const raw = await response.json();
36
+ if (!isPolicyConfig(raw)) throw new Error("Invalid policy response");
37
+ return raw;
38
+ }, { maxAttempts: 2 });
39
+ };
40
+ var getPolicy = async (apiKey) => {
41
+ const path = cacheFile(apiKey);
42
+ let staleCache = null;
43
+ if (existsSync(path)) {
44
+ try {
45
+ const stat = readFileSync(path, "utf-8");
46
+ const cached = JSON.parse(stat);
47
+ if (!isPolicyConfig(cached)) throw new Error("Corrupted cache");
48
+ if (!cached._cachedAt || Date.now() - cached._cachedAt < CACHE_TTL_MS) {
49
+ return cached;
50
+ }
51
+ staleCache = cached;
52
+ } catch {
53
+ }
54
+ }
55
+ try {
56
+ const policy = await fetchPolicy(apiKey);
57
+ try {
58
+ writeFileSync(path, JSON.stringify({ ...policy, _cachedAt: Date.now() }), "utf-8");
59
+ } catch {
60
+ }
61
+ return policy;
62
+ } catch {
63
+ if (staleCache) return staleCache;
64
+ throw new Error("Failed to fetch policy and no cached policy available");
65
+ }
66
+ };
67
+ var resolvePolicy = (config, toolName) => {
68
+ const exact = config.policies.find((p) => p.tool === toolName);
69
+ if (exact) return exact;
70
+ const wildcard = config.policies.find((p) => p.tool === "*");
71
+ if (wildcard) return wildcard;
72
+ return {
73
+ tool: toolName,
74
+ timeoutSeconds: config.defaultTimeoutSeconds,
75
+ timeoutAction: config.defaultTimeoutAction,
76
+ mode: config.defaultMode ?? "push_first",
77
+ pushFirstSeconds: config.defaultPushFirstSeconds ?? 10
78
+ };
79
+ };
80
+
81
+ // src/hook.ts
82
+ import { basename } from "path";
83
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
84
+ var allow = () => ({
85
+ hookSpecificOutput: {
86
+ hookEventName: "PreToolUse",
87
+ permissionDecision: "allow"
88
+ }
89
+ });
90
+ var deny = (reason) => ({
91
+ hookSpecificOutput: {
92
+ hookEventName: "PreToolUse",
93
+ permissionDecision: "deny",
94
+ permissionDecisionReason: reason
95
+ }
96
+ });
97
+ var ask = (reason) => ({
98
+ hookSpecificOutput: {
99
+ hookEventName: "PreToolUse",
100
+ permissionDecision: "ask",
101
+ ...reason ? { permissionDecisionReason: reason } : {}
102
+ }
103
+ });
104
+ var pollForAnswer = async (apiKey, correlationId, deadlineMs, pollInterval = 2e3) => {
105
+ while (Date.now() < deadlineMs) {
106
+ const remaining = Math.min(Math.max(deadlineMs - Date.now(), 1e3), 3e4);
107
+ let answer;
108
+ try {
109
+ answer = await waitForAnswer(apiKey, correlationId, remaining);
110
+ } catch {
111
+ if (Date.now() + pollInterval >= deadlineMs) break;
112
+ await sleep(pollInterval);
113
+ continue;
114
+ }
115
+ if (answer.answered) return answer;
116
+ if (Date.now() + pollInterval >= deadlineMs) break;
117
+ await sleep(pollInterval);
118
+ }
119
+ return { answered: false };
120
+ };
121
+ var handlePushOnly = async (apiKey, description, projectName, timeoutSeconds, timeoutAction) => {
122
+ let result;
123
+ try {
124
+ result = await askUser(apiKey, {
125
+ question: `Allow ${description}?`,
126
+ type: "confirm",
127
+ context: `Agent wants to run this in ${projectName}`,
128
+ agentName: `Claude Code - ${projectName}`
129
+ });
130
+ } catch {
131
+ switch (timeoutAction) {
132
+ case "approve":
133
+ return allow();
134
+ case "deny":
135
+ return deny("Push notification failed, denying per policy");
136
+ default:
137
+ return ask("Push notification failed, asking in terminal");
138
+ }
139
+ }
140
+ const deadline = Date.now() + timeoutSeconds * 1e3;
141
+ const answer = await pollForAnswer(apiKey, result.correlationId, deadline);
142
+ if (answer.answered) {
143
+ return answer.value === "yes" ? allow() : deny("Denied via push notification");
144
+ }
145
+ switch (timeoutAction) {
146
+ case "approve":
147
+ return allow();
148
+ case "deny":
149
+ return deny("No response within timeout");
150
+ default:
151
+ return ask("No push response, asking in terminal");
152
+ }
153
+ };
154
+ var handleTerminalOnly = () => {
155
+ return ask();
156
+ };
157
+ var handlePushFirst = async (apiKey, description, projectName, pushFirstSeconds) => {
158
+ let result;
159
+ try {
160
+ result = await askUser(apiKey, {
161
+ question: `Allow ${description}?`,
162
+ type: "confirm",
163
+ context: `Agent wants to run this in ${projectName}`,
164
+ agentName: `Claude Code - ${projectName}`
165
+ });
166
+ } catch {
167
+ return ask("Push notification failed, asking in terminal");
168
+ }
169
+ const deadline = Date.now() + pushFirstSeconds * 1e3;
170
+ const answer = await pollForAnswer(apiKey, result.correlationId, deadline, 1500);
171
+ if (answer.answered) {
172
+ return answer.value === "yes" ? allow() : deny("Denied via push notification");
173
+ }
174
+ savePendingQuestion(result.correlationId);
175
+ return ask("Sent as push notification. You can also approve here.");
176
+ };
177
+ var handleNotifyOnly = async (apiKey, description, projectName) => {
178
+ try {
179
+ await sendNotification(apiKey, {
180
+ title: "Agent needs approval",
181
+ body: description,
182
+ agentName: `Claude Code - ${projectName}`
183
+ });
184
+ } catch {
185
+ }
186
+ return ask();
187
+ };
188
+ var handlePreToolUse = async (input) => {
189
+ try {
190
+ const apiKey = getApiKey();
191
+ const policy = await getPolicy(apiKey);
192
+ const toolPolicy = resolvePolicy(policy, input.tool_name);
193
+ if (toolPolicy.timeoutSeconds === 0 && toolPolicy.timeoutAction === "approve") {
194
+ return allow();
195
+ }
196
+ const description = describeToolCall(input.tool_name, input.tool_input, "hook");
197
+ const projectName = basename(input.cwd ?? process.cwd());
198
+ switch (toolPolicy.mode) {
199
+ case "push_only":
200
+ return handlePushOnly(apiKey, description, projectName, toolPolicy.timeoutSeconds, toolPolicy.timeoutAction);
201
+ case "terminal_only":
202
+ return handleTerminalOnly();
203
+ case "push_first":
204
+ return handlePushFirst(apiKey, description, projectName, toolPolicy.pushFirstSeconds);
205
+ case "notify_only":
206
+ return handleNotifyOnly(apiKey, description, projectName);
207
+ default:
208
+ return handlePushFirst(apiKey, description, projectName, toolPolicy.pushFirstSeconds);
209
+ }
210
+ } catch {
211
+ return ask("Pushary unavailable, falling back to terminal approval");
212
+ }
213
+ };
214
+
215
+ export {
216
+ getPolicy,
217
+ resolvePolicy,
218
+ handlePreToolUse
219
+ };
@@ -11,7 +11,7 @@ interface HookOutput {
11
11
  permissionDecisionReason?: string;
12
12
  };
13
13
  }
14
- declare const handlePreToolUse: (input: ToolInput) => Promise<HookOutput | null>;
14
+ declare const handlePreToolUse: (input: ToolInput) => Promise<HookOutput>;
15
15
 
16
16
  interface AgentEvent {
17
17
  event: string;
package/dist/src/index.js CHANGED
@@ -2,22 +2,22 @@ import {
2
2
  getPolicy,
3
3
  handlePreToolUse,
4
4
  resolvePolicy
5
- } from "../chunk-DF3BM6BF.js";
5
+ } from "../chunk-WNXGIEX7.js";
6
6
  import {
7
7
  handleNotification,
8
8
  handlePostToolUse,
9
9
  handleStop,
10
10
  reportEvent
11
- } from "../chunk-P4JH2Q7Z.js";
11
+ } from "../chunk-KYARP7KP.js";
12
12
  import {
13
13
  askUser,
14
14
  cancelQuestion,
15
15
  waitForAnswer
16
- } from "../chunk-KTP2EPVB.js";
16
+ } from "../chunk-4Z4MB37G.js";
17
17
  import {
18
18
  getApiKey,
19
19
  getBaseUrl
20
- } from "../chunk-VIST7ACL.js";
20
+ } from "../chunk-O6A5RHWY.js";
21
21
  export {
22
22
  askUser,
23
23
  cancelQuestion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushary/agent-hooks",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Permission hooks for AI coding agents: route tool approvals through Pushary push notifications",
5
5
  "author": "Pushary <business@pushary.com>",
6
6
  "homepage": "https://pushary.com",
@@ -31,10 +31,11 @@
31
31
  "scripts": {
32
32
  "build": "tsup src/index.ts bin/pushary.ts bin/pushary-hook.ts bin/pushary-post-hook.ts bin/pushary-stop-hook.ts bin/pushary-codex.ts bin/pushary-setup.ts bin/pushary-clean.ts bin/pushary-doctor.ts --format esm --dts --outDir dist",
33
33
  "dev": "tsup src/index.ts bin/pushary.ts bin/pushary-hook.ts bin/pushary-post-hook.ts bin/pushary-stop-hook.ts bin/pushary-codex.ts bin/pushary-setup.ts bin/pushary-clean.ts bin/pushary-doctor.ts --format esm --watch",
34
- "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/mcp-http.test.ts"
34
+ "test": "bun test src/api.test.ts && bun test src/claude-config.test.ts && bun test src/mcp-http.test.ts && bun test src/retry.test.ts && bun test src/validate.test.ts && bun test src/policy.test.ts && bun test src/events.test.ts && bun test src/hook.test.ts"
35
35
  },
36
36
  "dependencies": {
37
- "@inquirer/prompts": "^8.4.2"
37
+ "@inquirer/prompts": "^8.4.2",
38
+ "smol-toml": "^1.6.1"
38
39
  },
39
40
  "devDependencies": {
40
41
  "tsup": "^8.0.0",