claude-remote-approver 0.3.6 → 0.4.1

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/ntfy.mjs +355 -0
package/README.md CHANGED
@@ -51,7 +51,7 @@ npm install -g claude-remote-approver
51
51
  claude-remote-approver setup
52
52
  ```
53
53
 
54
- Setup prints a QR code. Scan it with the ntfy app to subscribe, and you are done.
54
+ Setup prints a QR code. Scan it with the ntfy app to subscribe, then **start a new Claude Code session**. The hook is loaded at session startup, so any session that was already running before installation will not have the hook active.
55
55
 
56
56
  ## Installation
57
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-approver",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
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/ntfy.mjs CHANGED
@@ -69,6 +69,7 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
69
69
  if (parsed.requestId === requestId) {
70
70
  clearTimeout(timer);
71
71
  controller.signal.removeEventListener('abort', onAbort);
72
+ controller.abort();
72
73
  return { approved: parsed.approved };
73
74
  }
74
75
  } catch {
@@ -91,6 +92,345 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
91
92
  }
92
93
  }
93
94
 
95
+ /**
96
+ * Strip markdown formatting from text, returning plain text.
97
+ *
98
+ * @param {string} text - Markdown text to strip
99
+ * @returns {string} Plain text with markdown removed
100
+ */
101
+ export function stripMarkdown(text) {
102
+ // Input guard
103
+ if (text.length > MAX_INPUT) {
104
+ text = text.slice(0, MAX_INPUT);
105
+ }
106
+
107
+ // Order matters: fenced code blocks must be first to prevent processing markdown inside them.
108
+ let result = text
109
+ .replace(/```[\s\S]*?(?:```|$)/g, '') // Fenced code blocks
110
+ .replace(/^[ \t]*(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/gm, '') // Horizontal rules (before list markers)
111
+ .replace(/^#{1,6}\s+/gm, '') // Headers
112
+ .replace(/^(?:>[ \t]?)+/gm, '') // Block quotes
113
+ .replace(/^[ \t]*[-*+] /gm, '') // Unordered list markers with indent
114
+ .replace(/^[ \t]*\d+\. /gm, ''); // Ordered list markers with indent
115
+
116
+ result = stripInline(result);
117
+
118
+ return result.replace(/\n{2,}/g, '\n').trim();
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Constants and helpers
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const MAX_INPUT = 10000;
126
+
127
+ /**
128
+ * Count consecutive runs of character ch starting at pos.
129
+ *
130
+ * @param {string} text
131
+ * @param {number} pos
132
+ * @param {string} ch
133
+ * @returns {number}
134
+ */
135
+ function countRun(text, pos, ch) {
136
+ let count = 0;
137
+ while (pos + count < text.length && text[pos + count] === ch) count++;
138
+ return count;
139
+ }
140
+
141
+ const RE_ALPHANUMERIC = /[a-zA-Z0-9]/;
142
+ const RE_WHITESPACE = /\s/;
143
+ const RE_ASCII_PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/;
144
+
145
+ /**
146
+ * @param {string} ch
147
+ * @returns {boolean}
148
+ */
149
+ function isAlphanumeric(ch) { return RE_ALPHANUMERIC.test(ch); }
150
+
151
+ /**
152
+ * @param {string} ch
153
+ * @returns {boolean}
154
+ */
155
+ function isWhitespace(ch) { return RE_WHITESPACE.test(ch); }
156
+
157
+ /**
158
+ * @param {string} ch
159
+ * @returns {boolean}
160
+ */
161
+ function isAsciiPunctuation(ch) { return RE_ASCII_PUNCTUATION.test(ch); }
162
+
163
+ /**
164
+ * Precompute matched bracket/paren pairs using a stack in O(n).
165
+ * Skips backslash-escaped characters so that \[ \] \( \) don't create false pairs.
166
+ * Returns a Map from opening index to closing index.
167
+ *
168
+ * @param {string} str
169
+ * @param {string} open
170
+ * @param {string} close
171
+ * @returns {Map<number, number>}
172
+ */
173
+ function precomputePairs(str, open, close) {
174
+ const pairs = new Map();
175
+ const stack = [];
176
+ let i = 0;
177
+ while (i < str.length) {
178
+ if (str[i] === '\\' && i + 1 < str.length && isAsciiPunctuation(str[i + 1])) {
179
+ i += 2;
180
+ continue;
181
+ }
182
+ // Skip code spans — brackets inside are literal
183
+ if (str[i] === '`') {
184
+ const tickCount = countRun(str, i, '`');
185
+ const closeIdx = findBacktickCloser(str, tickCount, i + tickCount);
186
+ if (closeIdx !== -1) {
187
+ i = closeIdx + tickCount;
188
+ continue;
189
+ }
190
+ i += tickCount;
191
+ continue;
192
+ }
193
+ if (str[i] === open) stack.push(i);
194
+ else if (str[i] === close && stack.length > 0) {
195
+ pairs.set(stack.pop(), i);
196
+ }
197
+ i++;
198
+ }
199
+ return pairs;
200
+ }
201
+
202
+ /**
203
+ * Find exactly tickCount consecutive backticks (not more, not less).
204
+ * CommonMark: backslash inside code spans is literal, so no escape skipping.
205
+ *
206
+ * @param {string} text
207
+ * @param {number} tickCount
208
+ * @param {number} start
209
+ * @returns {number}
210
+ */
211
+ function findBacktickCloser(text, tickCount, start) {
212
+ let i = start;
213
+ while (i < text.length) {
214
+ if (text[i] === '`') {
215
+ const run = countRun(text, i, '`');
216
+ if (run === tickCount) return i;
217
+ i += run;
218
+ continue;
219
+ }
220
+ i++;
221
+ }
222
+ return -1;
223
+ }
224
+
225
+ /**
226
+ * Find a closing ~~ for strikethrough, skipping backslash-escaped characters.
227
+ *
228
+ * @param {string} text
229
+ * @param {number} start
230
+ * @returns {number}
231
+ */
232
+ function findStrikethroughCloser(text, start) {
233
+ let i = start;
234
+ while (i < text.length - 1) {
235
+ if (text[i] === '\\' && isAsciiPunctuation(text[i + 1])) {
236
+ i += 2;
237
+ continue;
238
+ }
239
+ if (text[i] === '~' && text[i + 1] === '~') return i;
240
+ i++;
241
+ }
242
+ return -1;
243
+ }
244
+
245
+ /**
246
+ * Find a closer for emphasis marker ch with at least markerLen consecutive chars.
247
+ * Skips \* and \_ (escaped markers).
248
+ * Closer condition: run >= markerLen AND preceding char is not whitespace.
249
+ *
250
+ * @param {string} text
251
+ * @param {string} ch
252
+ * @param {number} markerLen
253
+ * @param {number} start
254
+ * @returns {number}
255
+ */
256
+ function findEmphasisCloser(text, ch, markerLen, start) {
257
+ let i = start;
258
+ while (i < text.length) {
259
+ if (text[i] === '\\' && i + 1 < text.length && isAsciiPunctuation(text[i + 1])) {
260
+ i += 2;
261
+ continue;
262
+ }
263
+ if (text[i] === ch) {
264
+ const run = countRun(text, i, ch);
265
+ if (run >= markerLen && i > 0 && !isWhitespace(text[i - 1])) return i;
266
+ i += run;
267
+ continue;
268
+ }
269
+ i++;
270
+ }
271
+ return -1;
272
+ }
273
+
274
+ /**
275
+ * Handle emphasis markers (* or _).
276
+ * Returns { output, nextPos } on success, or null if the run should be treated as literal.
277
+ *
278
+ * @param {string} text
279
+ * @param {number} pos
280
+ * @returns {{ output: string, nextPos: number } | null}
281
+ */
282
+ function handleEmphasis(text, pos) {
283
+ const ch = text[pos];
284
+
285
+ // Count run length
286
+ const runLen = countRun(text, pos, ch);
287
+
288
+ // Opener condition:
289
+ // - prevChar is NOT alphanumeric (or start of string)
290
+ // - char after the run is NOT whitespace and not end of string
291
+ const prevChar = pos > 0 ? text[pos - 1] : '';
292
+ const afterIdx = pos + runLen;
293
+ const afterChar = afterIdx < text.length ? text[afterIdx] : '';
294
+
295
+ const isOpener = !isAlphanumeric(prevChar) && afterChar !== '' && !isWhitespace(afterChar);
296
+
297
+ if (!isOpener) return null;
298
+
299
+ // Try matching closest, longest-first (min(runLen, 3) down to 1)
300
+ const maxMarker = Math.min(runLen, 3);
301
+ // NOTE: O(n²) worst case when many openers lack closers (k openers × O(n) scan).
302
+ // Bounded by MAX_INPUT=10000; measured ~163ms worst case. Acceptable for notification text.
303
+ for (let markerLen = maxMarker; markerLen >= 1; markerLen--) {
304
+ let searchFrom = pos + runLen;
305
+ while (true) {
306
+ const idx = findEmphasisCloser(text, ch, markerLen, searchFrom);
307
+ if (idx === -1) break; // no closer found for this markerLen
308
+ const content = text.slice(pos + markerLen, idx);
309
+ if (content.length === 0) {
310
+ // Empty emphasis — skip this closer and keep searching
311
+ searchFrom = idx + countRun(text, idx, ch);
312
+ continue;
313
+ }
314
+ return { output: stripInline(content), nextPos: idx + markerLen };
315
+ }
316
+ }
317
+
318
+ return null;
319
+ }
320
+
321
+ /**
322
+ * Strip inline markdown formatting by scanning character by character.
323
+ *
324
+ * @param {string} text
325
+ * @returns {string}
326
+ */
327
+ function stripInline(text) {
328
+ // NOTE: Recursive calls for emphasis/strikethrough/link content.
329
+ // Depth bounded by nesting level (shallow in real-world markdown).
330
+ const bracketPairs = precomputePairs(text, '[', ']');
331
+ const parenPairs = precomputePairs(text, '(', ')');
332
+
333
+ let out = '';
334
+ let i = 0;
335
+
336
+ while (i < text.length) {
337
+ const ch = text[i];
338
+
339
+ if (ch === '\\' && i + 1 < text.length && isAsciiPunctuation(text[i + 1])) {
340
+ out += text[i + 1];
341
+ i += 2;
342
+ continue;
343
+ }
344
+
345
+ if (ch === '`') {
346
+ const tickCount = countRun(text, i, '`');
347
+ const closeIdx = findBacktickCloser(text, tickCount, i + tickCount);
348
+ if (closeIdx !== -1) {
349
+ out += text.slice(i + tickCount, closeIdx);
350
+ i = closeIdx + tickCount;
351
+ continue;
352
+ }
353
+ // Unclosed backtick(s) — output as literal
354
+ out += text.slice(i, i + tickCount);
355
+ i += tickCount;
356
+ continue;
357
+ }
358
+
359
+ if (ch === '!' && i + 1 < text.length && text[i + 1] === '[') {
360
+ const closeBracket = bracketPairs.get(i + 1);
361
+ if (closeBracket !== undefined && closeBracket + 1 < text.length && text[closeBracket + 1] === '(') {
362
+ const closeParen = parenPairs.get(closeBracket + 1);
363
+ if (closeParen !== undefined) {
364
+ const altText = text.slice(i + 2, closeBracket);
365
+ out += stripInline(altText);
366
+ i = closeParen + 1;
367
+ continue;
368
+ }
369
+ }
370
+ // Not a valid image — output ! as literal
371
+ out += ch;
372
+ i++;
373
+ continue;
374
+ }
375
+
376
+ if (ch === '[') {
377
+ const closeBracket = bracketPairs.get(i);
378
+ if (closeBracket !== undefined && closeBracket + 1 < text.length && text[closeBracket + 1] === '(') {
379
+ const closeParen = parenPairs.get(closeBracket + 1);
380
+ if (closeParen !== undefined) {
381
+ const linkText = text.slice(i + 1, closeBracket);
382
+ out += stripInline(linkText);
383
+ i = closeParen + 1;
384
+ continue;
385
+ }
386
+ }
387
+ // Not a valid link — output as literal
388
+ out += ch;
389
+ i++;
390
+ continue;
391
+ }
392
+
393
+ if (ch === '~' && i + 1 < text.length && text[i + 1] === '~') {
394
+ const searchStart = i + 2;
395
+ const closeIdx = findStrikethroughCloser(text, searchStart);
396
+ if (closeIdx !== -1) {
397
+ const content = text.slice(searchStart, closeIdx);
398
+ if (content.length === 0) {
399
+ out += '~~';
400
+ i += 2;
401
+ continue;
402
+ }
403
+ out += stripInline(content);
404
+ i = closeIdx + 2;
405
+ continue;
406
+ }
407
+ // No closer — output ~~ as literal
408
+ out += '~~';
409
+ i += 2;
410
+ continue;
411
+ }
412
+
413
+ if (ch === '*' || ch === '_') {
414
+ const result = handleEmphasis(text, i);
415
+ if (result !== null) {
416
+ out += result.output;
417
+ i = result.nextPos;
418
+ continue;
419
+ }
420
+ // Not an opener or no closer — output entire run as literal
421
+ const runLen = countRun(text, i, ch);
422
+ out += text.slice(i, i + runLen);
423
+ i += runLen;
424
+ continue;
425
+ }
426
+
427
+ out += ch;
428
+ i++;
429
+ }
430
+
431
+ return out;
432
+ }
433
+
94
434
  /**
95
435
  * Format tool information for display in the notification.
96
436
  *
@@ -98,6 +438,21 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
98
438
  * @returns {{ title: string, message: string }}
99
439
  */
100
440
  export function formatToolInfo({ hook_event_name, tool_name, tool_input }) {
441
+ // Plan approval detection
442
+ if (tool_name === 'ExitPlanMode' && typeof tool_input?.plan === 'string') {
443
+ const PLAN_MESSAGE_MAX_LENGTH = 300;
444
+ const title = 'Claude Code: Plan Review';
445
+ if (!tool_input.plan.trim()) {
446
+ return { title, message: '(empty plan)' };
447
+ }
448
+ const raw = tool_input.plan;
449
+ const plain = stripMarkdown(raw);
450
+ const message = plain
451
+ ? (plain.length > PLAN_MESSAGE_MAX_LENGTH ? plain.slice(0, PLAN_MESSAGE_MAX_LENGTH) + '...' : plain)
452
+ : '(empty plan)';
453
+ return { title, message };
454
+ }
455
+
101
456
  const title = `Claude Code: ${tool_name}`;
102
457
  let message;
103
458