claude-remote-approver 0.3.7 → 0.5.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/README.md +3 -1
- package/bin/cli.mjs +4 -4
- package/package.json +1 -1
- package/src/config.mjs +3 -1
- package/src/hook.mjs +156 -17
- package/src/ntfy.mjs +363 -5
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,
|
|
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
|
|
|
@@ -169,6 +169,7 @@ Config file location: `~/.claude-remote-approver.json`
|
|
|
169
169
|
"topic": "cra-a1b2c3d4e5f67890abcdef1234567890",
|
|
170
170
|
"ntfyServer": "https://ntfy.sh",
|
|
171
171
|
"timeout": 120,
|
|
172
|
+
"planTimeout": 300,
|
|
172
173
|
"autoApprove": [],
|
|
173
174
|
"autoDeny": []
|
|
174
175
|
}
|
|
@@ -179,6 +180,7 @@ Config file location: `~/.claude-remote-approver.json`
|
|
|
179
180
|
| `topic` | `string` | `""` | Your unique ntfy topic. Generated by `setup`. |
|
|
180
181
|
| `ntfyServer` | `string` | `"https://ntfy.sh"` | The ntfy server URL. Change this if you self-host. |
|
|
181
182
|
| `timeout` | `number` | `120` | Seconds to wait for a response before auto-denying. |
|
|
183
|
+
| `planTimeout` | `number` | `300` | Seconds to wait for ExitPlanMode (plan approval) responses. Plan reviews need more reading time. |
|
|
182
184
|
| `autoApprove` | `string[]` | `[]` | Reserved for future use. |
|
|
183
185
|
| `autoDeny` | `string[]` | `[]` | Reserved for future use. |
|
|
184
186
|
|
package/bin/cli.mjs
CHANGED
|
@@ -87,8 +87,8 @@ export async function main(args, deps) {
|
|
|
87
87
|
try {
|
|
88
88
|
input = JSON.parse(deps.stdin);
|
|
89
89
|
} catch {
|
|
90
|
-
const
|
|
91
|
-
deps.stdout.write(JSON.stringify(
|
|
90
|
+
const ask = { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "ask" } } };
|
|
91
|
+
deps.stdout.write(JSON.stringify(ask) + "\n");
|
|
92
92
|
break;
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -96,8 +96,8 @@ export async function main(args, deps) {
|
|
|
96
96
|
try {
|
|
97
97
|
result = await deps.processHook(input, deps);
|
|
98
98
|
} catch {
|
|
99
|
-
const
|
|
100
|
-
deps.stdout.write(JSON.stringify(
|
|
99
|
+
const ask = { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "ask" } } };
|
|
100
|
+
deps.stdout.write(JSON.stringify(ask) + "\n");
|
|
101
101
|
break;
|
|
102
102
|
}
|
|
103
103
|
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -9,6 +9,7 @@ export const DEFAULT_CONFIG = {
|
|
|
9
9
|
topic: "",
|
|
10
10
|
ntfyServer: "https://ntfy.sh",
|
|
11
11
|
timeout: 120,
|
|
12
|
+
planTimeout: 300,
|
|
12
13
|
// autoApprove/autoDeny are reserved for future use and not yet implemented
|
|
13
14
|
autoApprove: [],
|
|
14
15
|
autoDeny: [],
|
|
@@ -21,7 +22,8 @@ export function loadConfig(configPath = CONFIG_PATH) {
|
|
|
21
22
|
const config = { ...DEFAULT_CONFIG, ...fileConfig };
|
|
22
23
|
if (typeof config.topic !== "string") config.topic = DEFAULT_CONFIG.topic;
|
|
23
24
|
if (typeof config.ntfyServer !== "string") config.ntfyServer = DEFAULT_CONFIG.ntfyServer;
|
|
24
|
-
if (
|
|
25
|
+
if (!Number.isFinite(config.timeout) || config.timeout <= 0) config.timeout = DEFAULT_CONFIG.timeout;
|
|
26
|
+
if (!Number.isFinite(config.planTimeout) || config.planTimeout <= 0) config.planTimeout = DEFAULT_CONFIG.planTimeout;
|
|
25
27
|
if (!Array.isArray(config.autoApprove)) config.autoApprove = DEFAULT_CONFIG.autoApprove;
|
|
26
28
|
if (!Array.isArray(config.autoDeny)) config.autoDeny = DEFAULT_CONFIG.autoDeny;
|
|
27
29
|
return config;
|
package/src/hook.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// src/hook.mjs
|
|
2
2
|
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
|
+
import { DEFAULT_CONFIG } from "./config.mjs";
|
|
5
|
+
|
|
6
|
+
const ASK = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "ask" }) }) });
|
|
7
|
+
const DENY = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "deny" }) }) });
|
|
8
|
+
const MAX_RETRIES = 3;
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Build ntfy action buttons for Approve / Deny.
|
|
@@ -30,6 +35,135 @@ export function buildActions(server, topic, requestId) {
|
|
|
30
35
|
];
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Send with retry, returning null on exhausted retries.
|
|
40
|
+
*/
|
|
41
|
+
export async function sendWithRetry(sendFn, params) {
|
|
42
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
43
|
+
try {
|
|
44
|
+
return await sendFn(params);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (i === MAX_RETRIES - 1) {
|
|
47
|
+
console.error("sendNotification failed:", err);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if the input is an AskUserQuestion tool call with questions.
|
|
57
|
+
*/
|
|
58
|
+
export function isAskUserQuestion(input) {
|
|
59
|
+
return (
|
|
60
|
+
input?.tool_name === "AskUserQuestion" &&
|
|
61
|
+
Array.isArray(input?.tool_input?.questions) &&
|
|
62
|
+
input.tool_input.questions.length > 0
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build ntfy action buttons for question options.
|
|
68
|
+
*/
|
|
69
|
+
export function buildQuestionActions(server, topic, requestId, options) {
|
|
70
|
+
const url = `${server}/${topic}-response`;
|
|
71
|
+
return options.map((opt) => ({
|
|
72
|
+
action: "http",
|
|
73
|
+
label: opt.label,
|
|
74
|
+
url,
|
|
75
|
+
body: JSON.stringify({ requestId, answer: opt.label }),
|
|
76
|
+
method: "POST",
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a human-readable message for a question with options.
|
|
82
|
+
*/
|
|
83
|
+
export function buildQuestionMessage(question, options, opts = {}) {
|
|
84
|
+
const { multiSelect, batchInfo } = opts;
|
|
85
|
+
let msg = question;
|
|
86
|
+
if (batchInfo) msg += ` ${batchInfo}`;
|
|
87
|
+
if (multiSelect) msg += "\n(multiple selections allowed)";
|
|
88
|
+
msg += "\n\n";
|
|
89
|
+
for (const opt of options) {
|
|
90
|
+
msg += `• ${opt.label}: ${opt.description}\n`;
|
|
91
|
+
}
|
|
92
|
+
return msg.trimEnd();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Process an AskUserQuestion hook request.
|
|
97
|
+
*/
|
|
98
|
+
export async function processAskUserQuestion(input, deps) {
|
|
99
|
+
const config = deps.loadConfig();
|
|
100
|
+
if (!config.topic) return ASK;
|
|
101
|
+
|
|
102
|
+
const questions = input.tool_input.questions;
|
|
103
|
+
const answers = {};
|
|
104
|
+
|
|
105
|
+
for (const q of questions) {
|
|
106
|
+
const requestId = crypto.randomUUID();
|
|
107
|
+
const options = q.options;
|
|
108
|
+
|
|
109
|
+
const MAX_BUTTONS = 3;
|
|
110
|
+
const batches = [];
|
|
111
|
+
for (let j = 0; j < options.length; j += MAX_BUTTONS) {
|
|
112
|
+
batches.push(options.slice(j, j + MAX_BUTTONS));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < batches.length; i++) {
|
|
116
|
+
const batch = batches[i];
|
|
117
|
+
const batchInfo = batches.length > 1 ? `(${i + 1}/${batches.length})` : undefined;
|
|
118
|
+
const actions = buildQuestionActions(config.ntfyServer, config.topic, requestId, batch);
|
|
119
|
+
const message = buildQuestionMessage(q.question, batch, { multiSelect: q.multiSelect, batchInfo });
|
|
120
|
+
|
|
121
|
+
const sent = await sendWithRetry(deps.sendNotification, {
|
|
122
|
+
server: config.ntfyServer,
|
|
123
|
+
topic: config.topic,
|
|
124
|
+
title: `Claude Code: ${q.header || "Question"}`,
|
|
125
|
+
message,
|
|
126
|
+
actions,
|
|
127
|
+
requestId,
|
|
128
|
+
});
|
|
129
|
+
if (!sent) return ASK;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// AskUserQuestion uses standard timeout (not planTimeout)
|
|
133
|
+
let response;
|
|
134
|
+
try {
|
|
135
|
+
response = await deps.waitForResponse({
|
|
136
|
+
server: config.ntfyServer,
|
|
137
|
+
topic: config.topic,
|
|
138
|
+
requestId,
|
|
139
|
+
timeout: config.timeout * 1000,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("waitForResponse failed:", err);
|
|
143
|
+
return ASK;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (response.answer) {
|
|
147
|
+
answers[q.question] = response.answer;
|
|
148
|
+
} else {
|
|
149
|
+
return ASK;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
hookSpecificOutput: {
|
|
155
|
+
hookEventName: "PermissionRequest",
|
|
156
|
+
decision: {
|
|
157
|
+
behavior: "allow",
|
|
158
|
+
updatedInput: {
|
|
159
|
+
questions: input.tool_input.questions,
|
|
160
|
+
answers,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
33
167
|
/**
|
|
34
168
|
* Process a Claude Code hook request.
|
|
35
169
|
*
|
|
@@ -45,38 +179,43 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
|
|
|
45
179
|
const config = loadConfig();
|
|
46
180
|
|
|
47
181
|
if (!config.topic) {
|
|
48
|
-
return
|
|
182
|
+
return ASK;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isAskUserQuestion(input)) {
|
|
186
|
+
return processAskUserQuestion(input, { loadConfig, sendNotification, waitForResponse });
|
|
49
187
|
}
|
|
50
188
|
|
|
51
189
|
const requestId = crypto.randomUUID();
|
|
52
190
|
const { title, message } = formatToolInfo(input);
|
|
53
191
|
const actions = buildActions(config.ntfyServer, config.topic, requestId);
|
|
54
192
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
} catch (err) {
|
|
65
|
-
return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } };
|
|
66
|
-
}
|
|
193
|
+
const sent = await sendWithRetry(sendNotification, {
|
|
194
|
+
server: config.ntfyServer,
|
|
195
|
+
topic: config.topic,
|
|
196
|
+
title,
|
|
197
|
+
message,
|
|
198
|
+
actions,
|
|
199
|
+
requestId,
|
|
200
|
+
});
|
|
201
|
+
if (!sent) return ASK;
|
|
67
202
|
|
|
68
203
|
let response;
|
|
69
204
|
try {
|
|
205
|
+
const isPlanReview = input.tool_name === "ExitPlanMode";
|
|
206
|
+
const timeout = (isPlanReview ? (config.planTimeout ?? DEFAULT_CONFIG.planTimeout) : config.timeout) * 1000;
|
|
70
207
|
response = await waitForResponse({
|
|
71
208
|
server: config.ntfyServer,
|
|
72
209
|
topic: config.topic,
|
|
73
210
|
requestId,
|
|
74
|
-
timeout
|
|
211
|
+
timeout,
|
|
75
212
|
});
|
|
76
213
|
} catch (err) {
|
|
77
|
-
|
|
214
|
+
console.error("waitForResponse failed:", err);
|
|
215
|
+
return ASK;
|
|
78
216
|
}
|
|
79
217
|
|
|
80
|
-
|
|
81
|
-
|
|
218
|
+
if (response.timeout || response.error) return ASK;
|
|
219
|
+
if (response.approved === false) return DENY;
|
|
220
|
+
return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } };
|
|
82
221
|
}
|
package/src/ntfy.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export async function sendNotification({ server, topic, title, message, actions,
|
|
|
27
27
|
* Subscribe to the response topic via SSE and wait for a matching requestId.
|
|
28
28
|
*
|
|
29
29
|
* @param {{ server: string, topic: string, requestId: string, timeout: number }} params
|
|
30
|
-
* @returns {Promise<{ approved: boolean }>}
|
|
30
|
+
* @returns {Promise<{ approved: boolean } | { timeout: true } | { error: Error } | { answer: string }>}
|
|
31
31
|
*/
|
|
32
32
|
export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
33
33
|
const baseUrl = server.replace(/\/+$/, '');
|
|
@@ -70,6 +70,9 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
|
70
70
|
clearTimeout(timer);
|
|
71
71
|
controller.signal.removeEventListener('abort', onAbort);
|
|
72
72
|
controller.abort();
|
|
73
|
+
if (typeof parsed.answer === 'string') {
|
|
74
|
+
return { answer: parsed.answer };
|
|
75
|
+
}
|
|
73
76
|
return { approved: parsed.approved };
|
|
74
77
|
}
|
|
75
78
|
} catch {
|
|
@@ -82,16 +85,356 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
clearTimeout(timer);
|
|
85
|
-
return {
|
|
88
|
+
return { timeout: true };
|
|
86
89
|
} catch (err) {
|
|
87
90
|
if (timer !== undefined) clearTimeout(timer);
|
|
88
|
-
if (err?.name
|
|
89
|
-
|
|
91
|
+
if (err?.name === "AbortError") {
|
|
92
|
+
return { timeout: true };
|
|
90
93
|
}
|
|
91
|
-
|
|
94
|
+
console.error("[claude-remote-approver] waitForResponse error:", err);
|
|
95
|
+
return { error: err };
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Strip markdown formatting from text, returning plain text.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} text - Markdown text to strip
|
|
103
|
+
* @returns {string} Plain text with markdown removed
|
|
104
|
+
*/
|
|
105
|
+
export function stripMarkdown(text) {
|
|
106
|
+
// Input guard
|
|
107
|
+
if (text.length > MAX_INPUT) {
|
|
108
|
+
text = text.slice(0, MAX_INPUT);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Order matters: fenced code blocks must be first to prevent processing markdown inside them.
|
|
112
|
+
let result = text
|
|
113
|
+
.replace(/```[\s\S]*?(?:```|$)/g, '') // Fenced code blocks
|
|
114
|
+
.replace(/^[ \t]*(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/gm, '') // Horizontal rules (before list markers)
|
|
115
|
+
.replace(/^#{1,6}\s+/gm, '') // Headers
|
|
116
|
+
.replace(/^(?:>[ \t]?)+/gm, '') // Block quotes
|
|
117
|
+
.replace(/^[ \t]*[-*+] /gm, '') // Unordered list markers with indent
|
|
118
|
+
.replace(/^[ \t]*\d+\. /gm, ''); // Ordered list markers with indent
|
|
119
|
+
|
|
120
|
+
result = stripInline(result);
|
|
121
|
+
|
|
122
|
+
return result.replace(/\n{2,}/g, '\n').trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Constants and helpers
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const MAX_INPUT = 10000;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Count consecutive runs of character ch starting at pos.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @param {number} pos
|
|
136
|
+
* @param {string} ch
|
|
137
|
+
* @returns {number}
|
|
138
|
+
*/
|
|
139
|
+
function countRun(text, pos, ch) {
|
|
140
|
+
let count = 0;
|
|
141
|
+
while (pos + count < text.length && text[pos + count] === ch) count++;
|
|
142
|
+
return count;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const RE_ALPHANUMERIC = /[a-zA-Z0-9]/;
|
|
146
|
+
const RE_WHITESPACE = /\s/;
|
|
147
|
+
const RE_ASCII_PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} ch
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
function isAlphanumeric(ch) { return RE_ALPHANUMERIC.test(ch); }
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} ch
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
function isWhitespace(ch) { return RE_WHITESPACE.test(ch); }
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} ch
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function isAsciiPunctuation(ch) { return RE_ASCII_PUNCTUATION.test(ch); }
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Precompute matched bracket/paren pairs using a stack in O(n).
|
|
169
|
+
* Skips backslash-escaped characters so that \[ \] \( \) don't create false pairs.
|
|
170
|
+
* Returns a Map from opening index to closing index.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} str
|
|
173
|
+
* @param {string} open
|
|
174
|
+
* @param {string} close
|
|
175
|
+
* @returns {Map<number, number>}
|
|
176
|
+
*/
|
|
177
|
+
function precomputePairs(str, open, close) {
|
|
178
|
+
const pairs = new Map();
|
|
179
|
+
const stack = [];
|
|
180
|
+
let i = 0;
|
|
181
|
+
while (i < str.length) {
|
|
182
|
+
if (str[i] === '\\' && i + 1 < str.length && isAsciiPunctuation(str[i + 1])) {
|
|
183
|
+
i += 2;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Skip code spans — brackets inside are literal
|
|
187
|
+
if (str[i] === '`') {
|
|
188
|
+
const tickCount = countRun(str, i, '`');
|
|
189
|
+
const closeIdx = findBacktickCloser(str, tickCount, i + tickCount);
|
|
190
|
+
if (closeIdx !== -1) {
|
|
191
|
+
i = closeIdx + tickCount;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
i += tickCount;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (str[i] === open) stack.push(i);
|
|
198
|
+
else if (str[i] === close && stack.length > 0) {
|
|
199
|
+
pairs.set(stack.pop(), i);
|
|
200
|
+
}
|
|
201
|
+
i++;
|
|
202
|
+
}
|
|
203
|
+
return pairs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Find exactly tickCount consecutive backticks (not more, not less).
|
|
208
|
+
* CommonMark: backslash inside code spans is literal, so no escape skipping.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} text
|
|
211
|
+
* @param {number} tickCount
|
|
212
|
+
* @param {number} start
|
|
213
|
+
* @returns {number}
|
|
214
|
+
*/
|
|
215
|
+
function findBacktickCloser(text, tickCount, start) {
|
|
216
|
+
let i = start;
|
|
217
|
+
while (i < text.length) {
|
|
218
|
+
if (text[i] === '`') {
|
|
219
|
+
const run = countRun(text, i, '`');
|
|
220
|
+
if (run === tickCount) return i;
|
|
221
|
+
i += run;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
return -1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Find a closing ~~ for strikethrough, skipping backslash-escaped characters.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} text
|
|
233
|
+
* @param {number} start
|
|
234
|
+
* @returns {number}
|
|
235
|
+
*/
|
|
236
|
+
function findStrikethroughCloser(text, start) {
|
|
237
|
+
let i = start;
|
|
238
|
+
while (i < text.length - 1) {
|
|
239
|
+
if (text[i] === '\\' && isAsciiPunctuation(text[i + 1])) {
|
|
240
|
+
i += 2;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (text[i] === '~' && text[i + 1] === '~') return i;
|
|
244
|
+
i++;
|
|
245
|
+
}
|
|
246
|
+
return -1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Find a closer for emphasis marker ch with at least markerLen consecutive chars.
|
|
251
|
+
* Skips \* and \_ (escaped markers).
|
|
252
|
+
* Closer condition: run >= markerLen AND preceding char is not whitespace.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} text
|
|
255
|
+
* @param {string} ch
|
|
256
|
+
* @param {number} markerLen
|
|
257
|
+
* @param {number} start
|
|
258
|
+
* @returns {number}
|
|
259
|
+
*/
|
|
260
|
+
function findEmphasisCloser(text, ch, markerLen, start) {
|
|
261
|
+
let i = start;
|
|
262
|
+
while (i < text.length) {
|
|
263
|
+
if (text[i] === '\\' && i + 1 < text.length && isAsciiPunctuation(text[i + 1])) {
|
|
264
|
+
i += 2;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (text[i] === ch) {
|
|
268
|
+
const run = countRun(text, i, ch);
|
|
269
|
+
if (run >= markerLen && i > 0 && !isWhitespace(text[i - 1])) return i;
|
|
270
|
+
i += run;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
i++;
|
|
274
|
+
}
|
|
275
|
+
return -1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Handle emphasis markers (* or _).
|
|
280
|
+
* Returns { output, nextPos } on success, or null if the run should be treated as literal.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} text
|
|
283
|
+
* @param {number} pos
|
|
284
|
+
* @returns {{ output: string, nextPos: number } | null}
|
|
285
|
+
*/
|
|
286
|
+
function handleEmphasis(text, pos) {
|
|
287
|
+
const ch = text[pos];
|
|
288
|
+
|
|
289
|
+
// Count run length
|
|
290
|
+
const runLen = countRun(text, pos, ch);
|
|
291
|
+
|
|
292
|
+
// Opener condition:
|
|
293
|
+
// - prevChar is NOT alphanumeric (or start of string)
|
|
294
|
+
// - char after the run is NOT whitespace and not end of string
|
|
295
|
+
const prevChar = pos > 0 ? text[pos - 1] : '';
|
|
296
|
+
const afterIdx = pos + runLen;
|
|
297
|
+
const afterChar = afterIdx < text.length ? text[afterIdx] : '';
|
|
298
|
+
|
|
299
|
+
const isOpener = !isAlphanumeric(prevChar) && afterChar !== '' && !isWhitespace(afterChar);
|
|
300
|
+
|
|
301
|
+
if (!isOpener) return null;
|
|
302
|
+
|
|
303
|
+
// Try matching closest, longest-first (min(runLen, 3) down to 1)
|
|
304
|
+
const maxMarker = Math.min(runLen, 3);
|
|
305
|
+
// NOTE: O(n²) worst case when many openers lack closers (k openers × O(n) scan).
|
|
306
|
+
// Bounded by MAX_INPUT=10000; measured ~163ms worst case. Acceptable for notification text.
|
|
307
|
+
for (let markerLen = maxMarker; markerLen >= 1; markerLen--) {
|
|
308
|
+
let searchFrom = pos + runLen;
|
|
309
|
+
while (true) {
|
|
310
|
+
const idx = findEmphasisCloser(text, ch, markerLen, searchFrom);
|
|
311
|
+
if (idx === -1) break; // no closer found for this markerLen
|
|
312
|
+
const content = text.slice(pos + markerLen, idx);
|
|
313
|
+
if (content.length === 0) {
|
|
314
|
+
// Empty emphasis — skip this closer and keep searching
|
|
315
|
+
searchFrom = idx + countRun(text, idx, ch);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
return { output: stripInline(content), nextPos: idx + markerLen };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Strip inline markdown formatting by scanning character by character.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} text
|
|
329
|
+
* @returns {string}
|
|
330
|
+
*/
|
|
331
|
+
function stripInline(text) {
|
|
332
|
+
// NOTE: Recursive calls for emphasis/strikethrough/link content.
|
|
333
|
+
// Depth bounded by nesting level (shallow in real-world markdown).
|
|
334
|
+
const bracketPairs = precomputePairs(text, '[', ']');
|
|
335
|
+
const parenPairs = precomputePairs(text, '(', ')');
|
|
336
|
+
|
|
337
|
+
let out = '';
|
|
338
|
+
let i = 0;
|
|
339
|
+
|
|
340
|
+
while (i < text.length) {
|
|
341
|
+
const ch = text[i];
|
|
342
|
+
|
|
343
|
+
if (ch === '\\' && i + 1 < text.length && isAsciiPunctuation(text[i + 1])) {
|
|
344
|
+
out += text[i + 1];
|
|
345
|
+
i += 2;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (ch === '`') {
|
|
350
|
+
const tickCount = countRun(text, i, '`');
|
|
351
|
+
const closeIdx = findBacktickCloser(text, tickCount, i + tickCount);
|
|
352
|
+
if (closeIdx !== -1) {
|
|
353
|
+
out += text.slice(i + tickCount, closeIdx);
|
|
354
|
+
i = closeIdx + tickCount;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// Unclosed backtick(s) — output as literal
|
|
358
|
+
out += text.slice(i, i + tickCount);
|
|
359
|
+
i += tickCount;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (ch === '!' && i + 1 < text.length && text[i + 1] === '[') {
|
|
364
|
+
const closeBracket = bracketPairs.get(i + 1);
|
|
365
|
+
if (closeBracket !== undefined && closeBracket + 1 < text.length && text[closeBracket + 1] === '(') {
|
|
366
|
+
const closeParen = parenPairs.get(closeBracket + 1);
|
|
367
|
+
if (closeParen !== undefined) {
|
|
368
|
+
const altText = text.slice(i + 2, closeBracket);
|
|
369
|
+
out += stripInline(altText);
|
|
370
|
+
i = closeParen + 1;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Not a valid image — output ! as literal
|
|
375
|
+
out += ch;
|
|
376
|
+
i++;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (ch === '[') {
|
|
381
|
+
const closeBracket = bracketPairs.get(i);
|
|
382
|
+
if (closeBracket !== undefined && closeBracket + 1 < text.length && text[closeBracket + 1] === '(') {
|
|
383
|
+
const closeParen = parenPairs.get(closeBracket + 1);
|
|
384
|
+
if (closeParen !== undefined) {
|
|
385
|
+
const linkText = text.slice(i + 1, closeBracket);
|
|
386
|
+
out += stripInline(linkText);
|
|
387
|
+
i = closeParen + 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Not a valid link — output as literal
|
|
392
|
+
out += ch;
|
|
393
|
+
i++;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (ch === '~' && i + 1 < text.length && text[i + 1] === '~') {
|
|
398
|
+
const searchStart = i + 2;
|
|
399
|
+
const closeIdx = findStrikethroughCloser(text, searchStart);
|
|
400
|
+
if (closeIdx !== -1) {
|
|
401
|
+
const content = text.slice(searchStart, closeIdx);
|
|
402
|
+
if (content.length === 0) {
|
|
403
|
+
out += '~~';
|
|
404
|
+
i += 2;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
out += stripInline(content);
|
|
408
|
+
i = closeIdx + 2;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
// No closer — output ~~ as literal
|
|
412
|
+
out += '~~';
|
|
413
|
+
i += 2;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (ch === '*' || ch === '_') {
|
|
418
|
+
const result = handleEmphasis(text, i);
|
|
419
|
+
if (result !== null) {
|
|
420
|
+
out += result.output;
|
|
421
|
+
i = result.nextPos;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// Not an opener or no closer — output entire run as literal
|
|
425
|
+
const runLen = countRun(text, i, ch);
|
|
426
|
+
out += text.slice(i, i + runLen);
|
|
427
|
+
i += runLen;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
out += ch;
|
|
432
|
+
i++;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
|
|
95
438
|
/**
|
|
96
439
|
* Format tool information for display in the notification.
|
|
97
440
|
*
|
|
@@ -99,6 +442,21 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
|
99
442
|
* @returns {{ title: string, message: string }}
|
|
100
443
|
*/
|
|
101
444
|
export function formatToolInfo({ hook_event_name, tool_name, tool_input }) {
|
|
445
|
+
// Plan approval detection
|
|
446
|
+
if (tool_name === 'ExitPlanMode' && typeof tool_input?.plan === 'string') {
|
|
447
|
+
const PLAN_MESSAGE_MAX_LENGTH = 300;
|
|
448
|
+
const title = 'Claude Code: Plan Review';
|
|
449
|
+
if (!tool_input.plan.trim()) {
|
|
450
|
+
return { title, message: '(empty plan)' };
|
|
451
|
+
}
|
|
452
|
+
const raw = tool_input.plan;
|
|
453
|
+
const plain = stripMarkdown(raw);
|
|
454
|
+
const message = plain
|
|
455
|
+
? (plain.length > PLAN_MESSAGE_MAX_LENGTH ? plain.slice(0, PLAN_MESSAGE_MAX_LENGTH) + '...' : plain)
|
|
456
|
+
: '(empty plan)';
|
|
457
|
+
return { title, message };
|
|
458
|
+
}
|
|
459
|
+
|
|
102
460
|
const title = `Claude Code: ${tool_name}`;
|
|
103
461
|
let message;
|
|
104
462
|
|