@pushary/agent-hooks 0.26.0 → 0.27.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.
@@ -0,0 +1,208 @@
1
+ // ../../../../../packages/contracts/src/index.ts
2
+ var APPROVAL_MODES = ["push_only", "terminal_only", "push_first", "notify_only"];
3
+ var isApprovalMode = (value) => typeof value === "string" && APPROVAL_MODES.includes(value);
4
+ var HOOK_BUDGETS = {
5
+ claude: { budgetSeconds: 120, guardSeconds: 10 },
6
+ codex: { budgetSeconds: 180, guardSeconds: 10 },
7
+ gemini: { budgetSeconds: 180, guardSeconds: 10 },
8
+ // The Cursor gate is a dependency-free .mjs that cannot import this module; it
9
+ // mirrors these as MAX_BLOCK_MS (45s) inside its 60s failClosed budget.
10
+ cursor: { budgetSeconds: 60, guardSeconds: 15 }
11
+ };
12
+ var hookMaxWaitSeconds = (agent) => Math.max(HOOK_BUDGETS[agent].budgetSeconds - HOOK_BUDGETS[agent].guardSeconds, 1);
13
+ var hookWaitDeadline = (startMs, policyWaitSeconds, agent, nowMs) => {
14
+ const maxWait = hookMaxWaitSeconds(agent);
15
+ return Math.min(
16
+ nowMs + Math.min(Math.max(policyWaitSeconds, 0), maxWait) * 1e3,
17
+ startMs + maxWait * 1e3
18
+ );
19
+ };
20
+ var hookWaitClamped = (startMs, policyWaitSeconds, agent, nowMs) => startMs + hookMaxWaitSeconds(agent) * 1e3 < nowMs + Math.max(policyWaitSeconds, 0) * 1e3;
21
+ var effectiveWaitSeconds = (timeoutAction, policySeconds, agent) => timeoutAction === "wait" ? hookMaxWaitSeconds(agent) : policySeconds;
22
+ var MATCH_RANKS = ["none", "tool", "prefix", "exact"];
23
+ var matchRankWeight = (rank) => MATCH_RANKS.indexOf(rank);
24
+ var matchToolPattern = (pattern, toolName, arg) => {
25
+ const open = pattern.indexOf("(");
26
+ if (open === -1 || !pattern.endsWith(")")) {
27
+ return pattern === toolName ? "tool" : "none";
28
+ }
29
+ if (pattern.slice(0, open) !== toolName || arg === void 0) return "none";
30
+ const inner = pattern.slice(open + 1, -1);
31
+ if (inner.endsWith(":*")) {
32
+ return arg.startsWith(inner.slice(0, -2)) ? "prefix" : "none";
33
+ }
34
+ return arg === inner ? "exact" : "none";
35
+ };
36
+ var POLICY_ARG_KEYS = {
37
+ Bash: "command",
38
+ Edit: "file_path",
39
+ Write: "file_path"
40
+ };
41
+ var extractPolicyArg = (toolName, toolInput) => {
42
+ const key = POLICY_ARG_KEYS[toolName];
43
+ if (!key) return void 0;
44
+ const value = toolInput[key];
45
+ return typeof value === "string" ? value : void 0;
46
+ };
47
+ var SAFE_SHELL_COMMANDS = /* @__PURE__ */ new Set([
48
+ "ls",
49
+ "pwd",
50
+ "cd",
51
+ "cat",
52
+ "head",
53
+ "tail",
54
+ "wc",
55
+ "echo",
56
+ "printf",
57
+ "which",
58
+ "type",
59
+ "whoami",
60
+ "id",
61
+ "uname",
62
+ "arch",
63
+ "printenv",
64
+ "locale",
65
+ "tty",
66
+ "dirname",
67
+ "basename",
68
+ "realpath",
69
+ "readlink",
70
+ "stat",
71
+ "cut",
72
+ "nl",
73
+ "tr",
74
+ "comm",
75
+ "diff",
76
+ "cmp",
77
+ "grep",
78
+ "egrep",
79
+ "fgrep",
80
+ "jq",
81
+ "cksum",
82
+ "md5sum",
83
+ "sha1sum",
84
+ "sha256sum",
85
+ "du",
86
+ "df",
87
+ "ps",
88
+ "true"
89
+ ]);
90
+ var SAFE_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set([
91
+ "status",
92
+ "log",
93
+ "diff",
94
+ "show",
95
+ "rev-parse",
96
+ "describe",
97
+ "blame",
98
+ "shortlog",
99
+ "ls-files",
100
+ "ls-tree",
101
+ "cat-file",
102
+ "whatchanged",
103
+ "rev-list",
104
+ "name-rev",
105
+ "for-each-ref",
106
+ "var",
107
+ "count-objects"
108
+ ]);
109
+ var GIT_WRITE_FLAGS = (token) => token === "--output" || token.startsWith("--output=");
110
+ var UNSAFE_SHELL_CHARS = /[;&|<>(){}`\\\n\r]/;
111
+ var basenameOf = (token) => {
112
+ const slash = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
113
+ return slash === -1 ? token : token.slice(slash + 1);
114
+ };
115
+ var tokenizeShellCommand = (command) => {
116
+ const tokens = [];
117
+ let current = "";
118
+ let quote = null;
119
+ let started = false;
120
+ for (const ch of command) {
121
+ if (quote) {
122
+ if (ch === quote) quote = null;
123
+ else current += ch;
124
+ started = true;
125
+ continue;
126
+ }
127
+ if (ch === '"' || ch === "'") {
128
+ quote = ch;
129
+ started = true;
130
+ continue;
131
+ }
132
+ if (ch === " " || ch === " ") {
133
+ if (started) {
134
+ tokens.push(current);
135
+ current = "";
136
+ started = false;
137
+ }
138
+ continue;
139
+ }
140
+ current += ch;
141
+ started = true;
142
+ }
143
+ if (quote) return null;
144
+ if (started) tokens.push(current);
145
+ return tokens;
146
+ };
147
+ var isSafeReadOnlyCommand = (command) => {
148
+ const trimmed = command.trim();
149
+ if (!trimmed || trimmed.length > 2e3) return false;
150
+ if (UNSAFE_SHELL_CHARS.test(trimmed)) return false;
151
+ const tokens = tokenizeShellCommand(trimmed);
152
+ if (!tokens || tokens.length === 0) return false;
153
+ const first = tokens[0];
154
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first) || first.includes("$")) return false;
155
+ const exe = basenameOf(first);
156
+ if (exe === "git") {
157
+ const sub = tokens[1];
158
+ if (!sub || sub.startsWith("-")) return false;
159
+ if (!SAFE_GIT_SUBCOMMANDS.has(sub)) return false;
160
+ return !tokens.some(GIT_WRITE_FLAGS);
161
+ }
162
+ return SAFE_SHELL_COMMANDS.has(exe);
163
+ };
164
+ var API_KEY_PATTERN = /^pk_[a-f0-9]+\.[a-f0-9]+$/;
165
+ var isValidApiKey = (value) => API_KEY_PATTERN.test(value);
166
+ var ACTION_BODY_MAX = 4e3;
167
+ var DECISION_LINE_MAX = 500;
168
+ var SECRET_REDACTION_RULES = [
169
+ { pattern: /-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g, replacement: "[redacted key]" },
170
+ { pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, replacement: "[redacted]" },
171
+ { pattern: /\b[spr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, replacement: "[redacted]" },
172
+ { pattern: /\bwhsec_[A-Za-z0-9]{16,}\b/g, replacement: "[redacted]" },
173
+ { pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, replacement: "[redacted]" },
174
+ { pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, replacement: "[redacted]" },
175
+ { pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/g, replacement: "[redacted]" },
176
+ { pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replacement: "[redacted]" },
177
+ { pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g, replacement: "[redacted]" },
178
+ { pattern: /\bAKIA[0-9A-Z]{16}\b/g, replacement: "[redacted]" },
179
+ { pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: "[redacted]" },
180
+ { pattern: /\bxai-[A-Za-z0-9]{16,}\b/g, replacement: "[redacted]" },
181
+ { pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, replacement: "[redacted]" },
182
+ { pattern: /\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, replacement: "bearer [redacted]" },
183
+ { pattern: /\bauthorization:\s*\S+/gi, replacement: "authorization: [redacted]" },
184
+ {
185
+ pattern: /((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi,
186
+ replacement: "$1[redacted]"
187
+ }
188
+ ];
189
+ var HIGH_ENTROPY_RULE = { pattern: /[A-Za-z0-9+/]{40,}={0,2}/g, replacement: "[redacted]" };
190
+ var redactSecrets = (text) => SECRET_REDACTION_RULES.reduce((acc, rule) => acc.replace(rule.pattern, rule.replacement), text);
191
+ var redactSecretsDeep = (text) => redactSecrets(text).replace(HIGH_ENTROPY_RULE.pattern, HIGH_ENTROPY_RULE.replacement);
192
+
193
+ export {
194
+ isApprovalMode,
195
+ HOOK_BUDGETS,
196
+ hookWaitDeadline,
197
+ hookWaitClamped,
198
+ effectiveWaitSeconds,
199
+ matchRankWeight,
200
+ matchToolPattern,
201
+ extractPolicyArg,
202
+ isSafeReadOnlyCommand,
203
+ isValidApiKey,
204
+ ACTION_BODY_MAX,
205
+ DECISION_LINE_MAX,
206
+ redactSecrets,
207
+ redactSecretsDeep
208
+ };
@@ -0,0 +1,15 @@
1
+ // src/wait-ladder.ts
2
+ var describeWaitLadder = (p) => {
3
+ if (p.mode === "terminal_only") return "decide in the terminal, no push";
4
+ if (p.mode === "notify_only") return "send a heads-up, the terminal decides";
5
+ if (p.mode === "push_only") {
6
+ if (p.timeoutAction === "wait") return "wait for your phone, then ask in the terminal";
7
+ const tail = p.timeoutAction === "approve" ? "then auto-approve" : p.timeoutAction === "deny" ? "then auto-deny" : "then ask in the terminal";
8
+ return `wait for your phone up to ${p.timeoutSeconds}s, ${tail}`;
9
+ }
10
+ return `ping your phone ${p.pushFirstSeconds}s, then ask in the terminal`;
11
+ };
12
+
13
+ export {
14
+ describeWaitLadder
15
+ };
@@ -1,3 +1,7 @@
1
+ import {
2
+ HOOK_BUDGETS
3
+ } from "./chunk-FDR5WYT7.js";
4
+
1
5
  // src/claude-config.ts
2
6
  import { join } from "path";
3
7
  var PUSHARY_MCP_URL = "https://pushary.com/api/mcp/mcp";
@@ -64,7 +68,7 @@ var addPusharyHooks = (settings, binDir) => {
64
68
  hooks: [{
65
69
  type: "command",
66
70
  command: resolve("pushary-hook"),
67
- timeout: 120
71
+ timeout: HOOK_BUDGETS.claude.budgetSeconds
68
72
  }]
69
73
  });
70
74
  hooks.PreToolUse = preToolUse;
@@ -2,185 +2,22 @@ import {
2
2
  callMcpTool,
3
3
  withRetry
4
4
  } from "./chunk-DWED7BS3.js";
5
+ import {
6
+ ACTION_BODY_MAX,
7
+ DECISION_LINE_MAX,
8
+ extractPolicyArg,
9
+ isApprovalMode,
10
+ isSafeReadOnlyCommand,
11
+ matchRankWeight,
12
+ matchToolPattern,
13
+ redactSecrets,
14
+ redactSecretsDeep
15
+ } from "./chunk-FDR5WYT7.js";
5
16
  import {
6
17
  getApiKey,
7
18
  getBaseUrl
8
19
  } from "./chunk-NKXSILEW.js";
9
20
 
10
- // ../../../../../packages/contracts/src/index.ts
11
- var APPROVAL_MODES = ["push_only", "terminal_only", "push_first", "notify_only"];
12
- var isApprovalMode = (value) => typeof value === "string" && APPROVAL_MODES.includes(value);
13
- var MATCH_RANKS = ["none", "tool", "prefix", "exact"];
14
- var matchRankWeight = (rank) => MATCH_RANKS.indexOf(rank);
15
- var matchToolPattern = (pattern, toolName, arg) => {
16
- const open = pattern.indexOf("(");
17
- if (open === -1 || !pattern.endsWith(")")) {
18
- return pattern === toolName ? "tool" : "none";
19
- }
20
- if (pattern.slice(0, open) !== toolName || arg === void 0) return "none";
21
- const inner = pattern.slice(open + 1, -1);
22
- if (inner.endsWith(":*")) {
23
- return arg.startsWith(inner.slice(0, -2)) ? "prefix" : "none";
24
- }
25
- return arg === inner ? "exact" : "none";
26
- };
27
- var POLICY_ARG_KEYS = {
28
- Bash: "command",
29
- Edit: "file_path",
30
- Write: "file_path"
31
- };
32
- var extractPolicyArg = (toolName, toolInput) => {
33
- const key = POLICY_ARG_KEYS[toolName];
34
- if (!key) return void 0;
35
- const value = toolInput[key];
36
- return typeof value === "string" ? value : void 0;
37
- };
38
- var SAFE_SHELL_COMMANDS = /* @__PURE__ */ new Set([
39
- "ls",
40
- "pwd",
41
- "cd",
42
- "cat",
43
- "head",
44
- "tail",
45
- "wc",
46
- "echo",
47
- "printf",
48
- "which",
49
- "type",
50
- "whoami",
51
- "id",
52
- "uname",
53
- "arch",
54
- "printenv",
55
- "locale",
56
- "tty",
57
- "dirname",
58
- "basename",
59
- "realpath",
60
- "readlink",
61
- "stat",
62
- "cut",
63
- "nl",
64
- "tr",
65
- "comm",
66
- "diff",
67
- "cmp",
68
- "grep",
69
- "egrep",
70
- "fgrep",
71
- "jq",
72
- "cksum",
73
- "md5sum",
74
- "sha1sum",
75
- "sha256sum",
76
- "du",
77
- "df",
78
- "ps",
79
- "true"
80
- ]);
81
- var SAFE_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set([
82
- "status",
83
- "log",
84
- "diff",
85
- "show",
86
- "rev-parse",
87
- "describe",
88
- "blame",
89
- "shortlog",
90
- "ls-files",
91
- "ls-tree",
92
- "cat-file",
93
- "whatchanged",
94
- "rev-list",
95
- "name-rev",
96
- "for-each-ref",
97
- "var",
98
- "count-objects"
99
- ]);
100
- var GIT_WRITE_FLAGS = (token) => token === "--output" || token.startsWith("--output=");
101
- var UNSAFE_SHELL_CHARS = /[;&|<>(){}`\\\n\r]/;
102
- var basenameOf = (token) => {
103
- const slash = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
104
- return slash === -1 ? token : token.slice(slash + 1);
105
- };
106
- var tokenizeShellCommand = (command) => {
107
- const tokens = [];
108
- let current = "";
109
- let quote = null;
110
- let started = false;
111
- for (const ch of command) {
112
- if (quote) {
113
- if (ch === quote) quote = null;
114
- else current += ch;
115
- started = true;
116
- continue;
117
- }
118
- if (ch === '"' || ch === "'") {
119
- quote = ch;
120
- started = true;
121
- continue;
122
- }
123
- if (ch === " " || ch === " ") {
124
- if (started) {
125
- tokens.push(current);
126
- current = "";
127
- started = false;
128
- }
129
- continue;
130
- }
131
- current += ch;
132
- started = true;
133
- }
134
- if (quote) return null;
135
- if (started) tokens.push(current);
136
- return tokens;
137
- };
138
- var isSafeReadOnlyCommand = (command) => {
139
- const trimmed = command.trim();
140
- if (!trimmed || trimmed.length > 2e3) return false;
141
- if (UNSAFE_SHELL_CHARS.test(trimmed)) return false;
142
- const tokens = tokenizeShellCommand(trimmed);
143
- if (!tokens || tokens.length === 0) return false;
144
- const first = tokens[0];
145
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first) || first.includes("$")) return false;
146
- const exe = basenameOf(first);
147
- if (exe === "git") {
148
- const sub = tokens[1];
149
- if (!sub || sub.startsWith("-")) return false;
150
- if (!SAFE_GIT_SUBCOMMANDS.has(sub)) return false;
151
- return !tokens.some(GIT_WRITE_FLAGS);
152
- }
153
- return SAFE_SHELL_COMMANDS.has(exe);
154
- };
155
- var API_KEY_PATTERN = /^pk_[a-f0-9]+\.[a-f0-9]+$/;
156
- var isValidApiKey = (value) => API_KEY_PATTERN.test(value);
157
- var ACTION_BODY_MAX = 4e3;
158
- var DECISION_LINE_MAX = 500;
159
- var SECRET_REDACTION_RULES = [
160
- { pattern: /-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g, replacement: "[redacted key]" },
161
- { pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, replacement: "[redacted]" },
162
- { pattern: /\b[spr]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, replacement: "[redacted]" },
163
- { pattern: /\bwhsec_[A-Za-z0-9]{16,}\b/g, replacement: "[redacted]" },
164
- { pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g, replacement: "[redacted]" },
165
- { pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, replacement: "[redacted]" },
166
- { pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/g, replacement: "[redacted]" },
167
- { pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replacement: "[redacted]" },
168
- { pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g, replacement: "[redacted]" },
169
- { pattern: /\bAKIA[0-9A-Z]{16}\b/g, replacement: "[redacted]" },
170
- { pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: "[redacted]" },
171
- { pattern: /\bxai-[A-Za-z0-9]{16,}\b/g, replacement: "[redacted]" },
172
- { pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, replacement: "[redacted]" },
173
- { pattern: /\bbearer\s+[A-Za-z0-9._~+/=-]+/gi, replacement: "bearer [redacted]" },
174
- { pattern: /\bauthorization:\s*\S+/gi, replacement: "authorization: [redacted]" },
175
- {
176
- pattern: /((?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi,
177
- replacement: "$1[redacted]"
178
- }
179
- ];
180
- var HIGH_ENTROPY_RULE = { pattern: /[A-Za-z0-9+/]{40,}={0,2}/g, replacement: "[redacted]" };
181
- var redactSecrets = (text) => SECRET_REDACTION_RULES.reduce((acc, rule) => acc.replace(rule.pattern, rule.replacement), text);
182
- var redactSecretsDeep = (text) => redactSecrets(text).replace(HIGH_ENTROPY_RULE.pattern, HIGH_ENTROPY_RULE.replacement);
183
-
184
21
  // src/validate.ts
185
22
  var isPolicyConfig = (data) => {
186
23
  if (!data || typeof data !== "object") return false;
@@ -818,20 +655,52 @@ var readLastPrompt = (sessionId) => {
818
655
  return void 0;
819
656
  }
820
657
  };
821
- var cleanupPendingQuestions = async (sessionId) => {
658
+ var reconcilePendingQuestions = async (sessionId) => {
659
+ const late = [];
822
660
  try {
823
661
  const files = listPendingQuestions(sessionId);
662
+ if (files.length === 0) return late;
824
663
  const apiKey = getApiKey();
825
664
  for (const correlationId of files) {
826
665
  try {
827
- await cancelQuestion(apiKey, correlationId);
666
+ const answer = await waitForAnswer(apiKey, correlationId, 1e3);
667
+ if (answer.answered) {
668
+ late.push({ correlationId, value: answer.value });
669
+ continue;
670
+ }
671
+ await cancelQuestion(apiKey, correlationId).catch(() => {
672
+ });
828
673
  } catch {
674
+ continue;
829
675
  }
830
676
  removePendingQuestion(sessionId, correlationId);
831
677
  }
832
- if (!isDefaultSession(sessionId)) removePendingSession(sessionId);
833
678
  } catch {
834
679
  }
680
+ return late;
681
+ };
682
+ var describeLateAnswer = (value) => {
683
+ const v = (value ?? "").trim();
684
+ if (v === "yes") return "approved it";
685
+ if (v === "no") return "rejected it";
686
+ if (v === "defer") return "chose to handle it on this machine";
687
+ return v ? `answered "${v.slice(0, 200)}"` : "responded";
688
+ };
689
+ var buildLateAnswerContext = (late) => {
690
+ if (late.length === 0) return void 0;
691
+ const parts = late.map((l) => `you ${describeLateAnswer(l.value)} on your phone`);
692
+ return `Pushary: a permission prompt you were sent was answered late \u2014 ${parts.join("; ")} \u2014 after the terminal already took over. Treat this as the user's latest intent; do not assume the earlier terminal decision matched it.`;
693
+ };
694
+ var notifyLateAnswers = async (apiKey, late, agentName, sessionId) => {
695
+ if (late.length === 0) return;
696
+ const summary = late.map((l) => describeLateAnswer(l.value)).join(", ");
697
+ await sendNotification(apiKey, {
698
+ title: "Answer arrived late",
699
+ body: `You ${summary} after the agent had already moved on.`,
700
+ agentName,
701
+ sessionId
702
+ }, 2500).catch(() => {
703
+ });
835
704
  };
836
705
  var CLAUDE_CODE_AGENT = { type: "claude_code", label: "Claude Code" };
837
706
  var POLICY_CACHE_TTL_MS = 5 * 60 * 1e3;
@@ -917,10 +786,15 @@ var handlePostToolUse = async (input, agent = CLAUDE_CODE_AGENT) => {
917
786
  const receiptsEnabled = process.env.PUSHARY_RECEIPTS !== "off";
918
787
  const sessionKey = input.session_id || DEFAULT_SESSION;
919
788
  const liveMode = await fetchModeState(getApiKey(), input.session_id);
789
+ const late = await reconcilePendingQuestions(sessionKey);
790
+ let additionalContext;
791
+ if (late.length > 0 && agent.type === CLAUDE_CODE_AGENT.type) {
792
+ additionalContext = buildLateAnswerContext(late);
793
+ for (const l of late) removePendingQuestion(sessionKey, l.correlationId);
794
+ }
920
795
  const decisionSource = deriveDecisionSource(lookup.tool, lookup.input, liveMode);
921
796
  const beacon = decisionSource === "policy_auto" && throttlePass(`autodecision:${sessionKey}:${lookup.tool}`, AUTO_DECISION_WINDOW_MS) ? deriveAutoDecisionBeacon(lookup.tool, lookup.input, liveMode) : void 0;
922
797
  const reports = [
923
- cleanupPendingQuestions(sessionKey),
924
798
  reportEvent({
925
799
  event: isError ? "tool_error" : "tool_complete",
926
800
  agentType: agent.type,
@@ -948,16 +822,25 @@ var handlePostToolUse = async (input, agent = CLAUDE_CODE_AGENT) => {
948
822
  );
949
823
  }
950
824
  await Promise.allSettled(reports);
825
+ return additionalContext;
951
826
  } catch {
827
+ return void 0;
952
828
  }
953
829
  };
954
830
  var TASK_TITLE_MAX_LENGTH = 120;
955
831
  var handleUserPrompt = async (input, agent = CLAUDE_CODE_AGENT) => {
956
832
  try {
957
833
  const projectName = basename(input.cwd ?? process.cwd());
834
+ const sessionKey = input.session_id || DEFAULT_SESSION;
958
835
  const titlesEnabled = process.env.PUSHARY_TASK_TITLES !== "off";
959
836
  const taskTitle = titlesEnabled ? input.prompt?.replace(/\s+/g, " ").trim().slice(0, TASK_TITLE_MAX_LENGTH) || void 0 : void 0;
960
- if (input.prompt) saveLastPrompt(input.session_id || DEFAULT_SESSION, input.prompt);
837
+ if (input.prompt) saveLastPrompt(sessionKey, input.prompt);
838
+ const late = await reconcilePendingQuestions(sessionKey);
839
+ let additionalContext;
840
+ if (late.length > 0 && agent.type === CLAUDE_CODE_AGENT.type) {
841
+ additionalContext = buildLateAnswerContext(late);
842
+ for (const l of late) removePendingQuestion(sessionKey, l.correlationId);
843
+ }
961
844
  await reportEvent({
962
845
  event: "user_prompt",
963
846
  agentType: agent.type,
@@ -965,14 +848,17 @@ var handleUserPrompt = async (input, agent = CLAUDE_CODE_AGENT) => {
965
848
  sessionId: input.session_id,
966
849
  taskTitle
967
850
  }, { maxAttempts: 1, timeoutMs: 800 });
851
+ return additionalContext;
968
852
  } catch {
853
+ return void 0;
969
854
  }
970
855
  };
971
856
  var handleStop = async (input, agent = CLAUDE_CODE_AGENT) => {
972
857
  try {
973
858
  const projectName = basename(input.cwd ?? process.cwd());
974
- const [, reported] = await Promise.allSettled([
975
- cleanupPendingQuestions(input.session_id || DEFAULT_SESSION),
859
+ const sessionKey = input.session_id || DEFAULT_SESSION;
860
+ const late = await reconcilePendingQuestions(sessionKey);
861
+ const tasks = [
976
862
  reportEvent({
977
863
  event: "session_end",
978
864
  agentType: agent.type,
@@ -981,7 +867,13 @@ var handleStop = async (input, agent = CLAUDE_CODE_AGENT) => {
981
867
  sessionId: input.session_id,
982
868
  usage: deriveUsage(input.transcript_path, input.session_id)
983
869
  })
984
- ]);
870
+ ];
871
+ if (late.length > 0) {
872
+ tasks.push(notifyLateAnswers(getApiKey(), late, `${agent.label} - ${projectName}`, input.session_id));
873
+ for (const l of late) removePendingQuestion(sessionKey, l.correlationId);
874
+ }
875
+ if (!isDefaultSession(sessionKey)) removePendingSession(sessionKey);
876
+ const [reported] = await Promise.allSettled(tasks);
985
877
  const pendingCommand = reported.status === "fulfilled" ? reported.value?.pendingCommand : void 0;
986
878
  if (typeof pendingCommand === "string" && pendingCommand.trim().length > 0) {
987
879
  return {
@@ -1010,8 +902,6 @@ var handleNotification = async (input) => {
1010
902
  };
1011
903
 
1012
904
  export {
1013
- isValidApiKey,
1014
- DECISION_LINE_MAX,
1015
905
  askUser,
1016
906
  waitForAnswer,
1017
907
  cancelQuestion,
@@ -5,6 +5,7 @@ import {
5
5
  import {
6
6
  DEFAULT_SESSION,
7
7
  askUser,
8
+ cancelQuestion,
8
9
  deriveAction,
9
10
  deriveActionBody,
10
11
  deriveBlocker,
@@ -19,7 +20,12 @@ import {
19
20
  savePendingQuestion,
20
21
  sendNotification,
21
22
  waitForAnswer
22
- } from "./chunk-A7DQCA2N.js";
23
+ } from "./chunk-TUILTFLS.js";
24
+ import {
25
+ effectiveWaitSeconds,
26
+ hookWaitClamped,
27
+ hookWaitDeadline
28
+ } from "./chunk-FDR5WYT7.js";
23
29
  import {
24
30
  getApiKey
25
31
  } from "./chunk-NKXSILEW.js";
@@ -27,6 +33,7 @@ import {
27
33
  // src/hook.ts
28
34
  import { basename } from "path";
29
35
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
36
+ var START_MS = Date.now();
30
37
  var allow = () => ({
31
38
  hookSpecificOutput: {
32
39
  hookEventName: "PreToolUse",
@@ -88,12 +95,34 @@ var handlePushOnly = async (apiKey, description, projectName, timeoutSeconds, ti
88
95
  return ask("Push notification failed, asking in terminal");
89
96
  }
90
97
  }
91
- const deadline = Date.now() + timeoutSeconds * 1e3;
98
+ if (result.suppressed) {
99
+ await cancelQuestion(apiKey, result.correlationId).catch(() => {
100
+ });
101
+ return ask("You are at the keyboard \u2014 approve here.");
102
+ }
103
+ if (result.noDevices) {
104
+ switch (timeoutAction) {
105
+ case "approve":
106
+ return allow();
107
+ case "deny":
108
+ return deny("No device connected to approve on");
109
+ default:
110
+ return ask("No device connected \u2014 asking in terminal");
111
+ }
112
+ }
113
+ const effectiveWait = effectiveWaitSeconds(timeoutAction, timeoutSeconds, "claude");
114
+ const now = Date.now();
115
+ const deadline = hookWaitDeadline(START_MS, effectiveWait, "claude", now);
116
+ const clampCut = hookWaitClamped(START_MS, effectiveWait, "claude", now);
92
117
  const answer = await pollForAnswer(apiKey, result.correlationId, deadline);
93
118
  if (answer.answered) {
94
119
  if (isDeferAnswer(answer.value)) return ask("Handling on your machine");
95
120
  return answer.value === "yes" ? allow() : deny(denyReasonFrom(answer.value));
96
121
  }
122
+ if (timeoutAction === "wait" || clampCut) {
123
+ savePendingQuestion(sessionId || DEFAULT_SESSION, result.correlationId);
124
+ return ask(timeoutAction === "wait" ? "Waited for your phone \u2014 asking here now." : "Approval window exceeds this hook budget \u2014 asking here.");
125
+ }
97
126
  switch (timeoutAction) {
98
127
  case "approve":
99
128
  return allow();
@@ -123,7 +152,15 @@ var handlePushFirst = async (apiKey, description, projectName, pushFirstSeconds,
123
152
  } catch {
124
153
  return ask("Push notification failed, asking in terminal");
125
154
  }
126
- const deadline = Date.now() + pushFirstSeconds * 1e3;
155
+ if (result.suppressed) {
156
+ await cancelQuestion(apiKey, result.correlationId).catch(() => {
157
+ });
158
+ return ask("You are at the keyboard \u2014 approve here.");
159
+ }
160
+ if (result.noDevices) {
161
+ return ask("No device connected \u2014 asking in terminal.");
162
+ }
163
+ const deadline = hookWaitDeadline(START_MS, pushFirstSeconds, "claude", Date.now());
127
164
  const answer = await pollForAnswer(apiKey, result.correlationId, deadline, 1500);
128
165
  if (answer.answered) {
129
166
  if (isDeferAnswer(answer.value)) return ask("Handling on your machine");
@@ -1,9 +1,13 @@
1
+ import {
2
+ HOOK_BUDGETS
3
+ } from "./chunk-FDR5WYT7.js";
4
+
1
5
  // src/codex-config.ts
2
6
  import { createHash } from "crypto";
3
7
  var CODEX_HOOK_BINARY = "pushary-codex-hook";
4
8
  var CODEX_HOOK_EVENTS = [
5
- { event: "PermissionRequest", matcher: "Bash|apply_patch", timeout: 180, statusMessage: "Waiting for your phone" },
6
- { event: "PreToolUse", matcher: "Bash|apply_patch", timeout: 180, statusMessage: "Checking Pushary policy" },
9
+ { event: "PermissionRequest", matcher: "Bash|apply_patch", timeout: HOOK_BUDGETS.codex.budgetSeconds, statusMessage: "Waiting for your phone" },
10
+ { event: "PreToolUse", matcher: "Bash|apply_patch", timeout: HOOK_BUDGETS.codex.budgetSeconds, statusMessage: "Checking Pushary policy" },
7
11
  { event: "PostToolUse", matcher: "Bash|apply_patch", timeout: 10 },
8
12
  { event: "UserPromptSubmit", timeout: 10 },
9
13
  { event: "Stop", timeout: 10 },
@@ -141,7 +145,7 @@ var addCodexHookTrust = (config, hooksJsonPath, command) => {
141
145
  var GEMINI_HOOK_BINARY = "pushary-gemini-hook";
142
146
  var GEMINI_MCP_URL = "https://pushary.com/api/mcp/mcp";
143
147
  var GEMINI_HOOK_EVENTS = [
144
- { event: "BeforeTool", matcher: "run_shell_command|write_file|replace", timeoutMs: 18e4 },
148
+ { event: "BeforeTool", matcher: "run_shell_command|write_file|replace", timeoutMs: HOOK_BUDGETS.gemini.budgetSeconds * 1e3 },
145
149
  { event: "AfterTool", matcher: "run_shell_command|write_file|replace", timeoutMs: 1e4 },
146
150
  { event: "BeforeAgent", timeoutMs: 1e4 },
147
151
  { event: "SessionStart", timeoutMs: 1e4 },