claude-remote-approver 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -87,7 +87,7 @@ export async function main(args, deps) {
87
87
  try {
88
88
  input = JSON.parse(deps.stdin);
89
89
  } catch {
90
- const deny = { hookSpecificOutput: { decision: { behavior: "deny" } } };
90
+ const deny = { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } };
91
91
  deps.stdout.write(JSON.stringify(deny) + "\n");
92
92
  break;
93
93
  }
@@ -96,7 +96,7 @@ export async function main(args, deps) {
96
96
  try {
97
97
  result = await deps.processHook(input, deps);
98
98
  } catch {
99
- const deny = { hookSpecificOutput: { decision: { behavior: "deny" } } };
99
+ const deny = { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } };
100
100
  deps.stdout.write(JSON.stringify(deny) + "\n");
101
101
  break;
102
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-approver",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Approve or deny Claude Code permission prompts remotely from your phone via ntfy.sh",
5
5
  "type": "module",
6
6
  "bin": {
package/src/hook.mjs CHANGED
@@ -45,7 +45,7 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
45
45
  const config = loadConfig();
46
46
 
47
47
  if (!config.topic) {
48
- return { hookSpecificOutput: { decision: { behavior: "deny" } } };
48
+ return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } };
49
49
  }
50
50
 
51
51
  const requestId = crypto.randomUUID();
@@ -61,8 +61,8 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
61
61
  actions,
62
62
  requestId,
63
63
  });
64
- } catch {
65
- return { hookSpecificOutput: { decision: { behavior: "deny" } } };
64
+ } catch (err) {
65
+ return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } };
66
66
  }
67
67
 
68
68
  let response;
@@ -73,10 +73,10 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
73
73
  requestId,
74
74
  timeout: config.timeout * 1000,
75
75
  });
76
- } catch {
77
- return { hookSpecificOutput: { decision: { behavior: "deny" } } };
76
+ } catch (err) {
77
+ return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } };
78
78
  }
79
79
 
80
80
  const behavior = response.approved ? "allow" : "deny";
81
- return { hookSpecificOutput: { decision: { behavior } } };
81
+ return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior } } };
82
82
  }
package/src/ntfy.mjs CHANGED
@@ -24,44 +24,72 @@ export async function sendNotification({ server, topic, title, message, actions,
24
24
  }
25
25
 
26
26
  /**
27
- * Poll the response topic and wait for a matching requestId.
27
+ * Subscribe to the response topic via SSE and wait for a matching requestId.
28
28
  *
29
- * @param {{ server: string, topic: string, requestId: string, timeout: number, pollInterval?: number }} params
29
+ * @param {{ server: string, topic: string, requestId: string, timeout: number }} params
30
30
  * @returns {Promise<{ approved: boolean }>}
31
31
  */
32
- export async function waitForResponse({ server, topic, requestId, timeout, pollInterval = 2000 }) {
32
+ export async function waitForResponse({ server, topic, requestId, timeout }) {
33
33
  const baseUrl = server.replace(/\/+$/, '');
34
- const sinceTimestamp = Math.floor(Date.now() / 1000);
35
- const pollUrl = `${baseUrl}/${topic}-response/json?poll=1&since=${sinceTimestamp}`;
36
- const startTime = Date.now();
34
+ const url = `${baseUrl}/${topic}-response/json`;
35
+
36
+ const controller = new AbortController();
37
+
38
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
39
+ let timer;
40
+
41
+ try {
42
+ const response = await fetch(url, { signal: controller.signal });
43
+ const reader = response.body.getReader();
44
+ const decoder = new TextDecoder();
45
+ let buffer = '';
46
+
47
+ // Listen to abort so we can cancel the reader even when the mock stream
48
+ // never closes (the real fetch would propagate the signal, but mocks may not).
49
+ const onAbort = () => reader.cancel();
50
+ controller.signal.addEventListener('abort', onAbort);
51
+
52
+ // Start the timeout AFTER fetch resolves so we measure waiting time only.
53
+ timer = setTimeout(() => controller.abort(), timeout);
37
54
 
38
- while (Date.now() - startTime < timeout) {
39
55
  try {
40
- const response = await fetch(pollUrl);
41
- if (!response.ok) continue;
42
- const text = await response.text();
43
- const lines = text.trim().split('\n');
44
-
45
- for (const line of lines) {
46
- if (!line.trim()) continue;
47
- try {
48
- const event = JSON.parse(line);
49
- const parsed = JSON.parse(event.message);
50
- if (parsed.requestId === requestId) {
51
- return { approved: parsed.approved === true };
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done) break;
59
+
60
+ buffer += decoder.decode(value, { stream: true });
61
+ const lines = buffer.split('\n');
62
+ buffer = lines.pop();
63
+
64
+ for (const line of lines) {
65
+ if (!line.trim()) continue;
66
+ try {
67
+ const event = JSON.parse(line);
68
+ const parsed = JSON.parse(event.message);
69
+ if (parsed.requestId === requestId) {
70
+ clearTimeout(timer);
71
+ controller.signal.removeEventListener('abort', onAbort);
72
+ controller.abort();
73
+ return { approved: parsed.approved };
74
+ }
75
+ } catch {
76
+ // skip non-JSON lines
52
77
  }
53
- } catch {
54
- // skip non-JSON lines
55
78
  }
56
79
  }
57
- } catch (err) {
58
- console.error("[claude-remote-approver] poll error:", err);
80
+ } finally {
81
+ controller.signal.removeEventListener('abort', onAbort);
59
82
  }
60
83
 
61
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
84
+ clearTimeout(timer);
85
+ return { approved: false };
86
+ } catch (err) {
87
+ if (timer !== undefined) clearTimeout(timer);
88
+ if (err?.name !== "AbortError") {
89
+ console.error("[claude-remote-approver] waitForResponse error:", err);
90
+ }
91
+ return { approved: false };
62
92
  }
63
-
64
- return { approved: false };
65
93
  }
66
94
 
67
95
  /**