@stackmemoryai/stackmemory 0.5.15 → 0.5.17

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.
@@ -110,19 +110,19 @@ process.stdin.on('end', () => {
110
110
  // Only process Bash tool
111
111
  if (tool_name !== 'Bash') {
112
112
  // Allow other tools through unchanged
113
- console.log(JSON.stringify({ decision: 'allow' }));
113
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
114
114
  return;
115
115
  }
116
116
 
117
117
  const command = tool_input?.command;
118
118
  if (!command) {
119
- console.log(JSON.stringify({ decision: 'allow' }));
119
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
120
120
  return;
121
121
  }
122
122
 
123
123
  // Already backgrounded
124
124
  if (tool_input.run_in_background === true) {
125
- console.log(JSON.stringify({ decision: 'allow' }));
125
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
126
126
  return;
127
127
  }
128
128
 
@@ -135,22 +135,23 @@ process.stdin.on('end', () => {
135
135
  );
136
136
  }
137
137
 
138
- // Modify the tool input to add run_in_background
138
+ // Modify the tool input to add run_in_background using correct schema
139
139
  console.log(
140
140
  JSON.stringify({
141
- decision: 'modify',
142
- tool_input: {
141
+ hookEventName: 'PreToolUse',
142
+ permissionDecision: 'allow',
143
+ updatedInput: {
143
144
  ...tool_input,
144
145
  run_in_background: true,
145
146
  },
146
147
  })
147
148
  );
148
149
  } else {
149
- console.log(JSON.stringify({ decision: 'allow' }));
150
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
150
151
  }
151
152
  } catch (err) {
152
153
  // On error, allow the command through unchanged
153
154
  console.error('[auto-bg] Error:', err.message);
154
- console.log(JSON.stringify({ decision: 'allow' }));
155
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
155
156
  }
156
157
  });
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Claude Code hook for SMS notifications on review-ready events
3
+ * Claude Code hook for WhatsApp/SMS notifications
4
4
  *
5
5
  * Triggers notifications when:
6
+ * - AskUserQuestion tool is used (allows remote response)
6
7
  * - PR is created
7
8
  * - Task is marked complete
8
9
  * - User explicitly requests notification
@@ -15,7 +16,35 @@ const path = require('path');
15
16
  const os = require('os');
16
17
  const https = require('https');
17
18
 
19
+ // Load .env files (check multiple locations)
20
+ const envPaths = [
21
+ path.join(process.cwd(), '.env'),
22
+ path.join(os.homedir(), 'Dev/stackmemory/.env'),
23
+ path.join(os.homedir(), '.stackmemory/.env'),
24
+ path.join(os.homedir(), '.env'),
25
+ ];
26
+ for (const envPath of envPaths) {
27
+ if (fs.existsSync(envPath)) {
28
+ try {
29
+ const content = fs.readFileSync(envPath, 'utf8');
30
+ for (const line of content.split('\n')) {
31
+ const match = line.match(/^([^#=]+)=(.*)$/);
32
+ if (match && !process.env[match[1].trim()]) {
33
+ process.env[match[1].trim()] = match[2]
34
+ .trim()
35
+ .replace(/^["']|["']$/g, '');
36
+ }
37
+ }
38
+ } catch {}
39
+ }
40
+ }
41
+
18
42
  const CONFIG_PATH = path.join(os.homedir(), '.stackmemory', 'sms-notify.json');
43
+ const DEBUG_LOG = path.join(
44
+ os.homedir(),
45
+ '.stackmemory',
46
+ 'claude-session-debug.log'
47
+ );
19
48
 
20
49
  function loadConfig() {
21
50
  try {
@@ -23,13 +52,103 @@ function loadConfig() {
23
52
  return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
24
53
  }
25
54
  } catch {}
26
- return { enabled: false };
55
+ return { enabled: false, pendingPrompts: [] };
56
+ }
57
+
58
+ function saveConfig(config) {
59
+ try {
60
+ const dir = path.join(os.homedir(), '.stackmemory');
61
+ if (!fs.existsSync(dir)) {
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ }
64
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
65
+ } catch (err) {
66
+ console.error('[notify-hook] Failed to save config:', err.message);
67
+ }
68
+ }
69
+
70
+ function logDebug(event, data) {
71
+ try {
72
+ const entry = `[${new Date().toISOString()}] ${event}: ${typeof data === 'string' ? data : JSON.stringify(data)}\n`;
73
+ fs.appendFileSync(DEBUG_LOG, entry);
74
+ } catch {}
75
+ }
76
+
77
+ function savePendingPrompt(prompt) {
78
+ try {
79
+ const config = loadConfig();
80
+ if (!config.pendingPrompts) {
81
+ config.pendingPrompts = [];
82
+ }
83
+ config.pendingPrompts.push(prompt);
84
+ // Keep only last 10 prompts
85
+ if (config.pendingPrompts.length > 10) {
86
+ config.pendingPrompts = config.pendingPrompts.slice(-10);
87
+ }
88
+ saveConfig(config);
89
+ logDebug('PENDING_PROMPT', {
90
+ id: prompt.id,
91
+ options: prompt.options.length,
92
+ });
93
+ } catch (err) {
94
+ console.error('[notify-hook] Failed to save pending prompt:', err.message);
95
+ }
27
96
  }
28
97
 
29
98
  function shouldNotify(toolName, toolInput, output) {
30
99
  const config = loadConfig();
31
100
  if (!config.enabled) return null;
32
101
 
102
+ // AskUserQuestion - send question via WhatsApp for remote response
103
+ if (toolName === 'AskUserQuestion') {
104
+ const questions = toolInput?.questions || [];
105
+ if (questions.length === 0) return null;
106
+
107
+ // Take first question (most common case)
108
+ const q = questions[0];
109
+ const promptId = Math.random().toString(36).substring(2, 10);
110
+
111
+ // Build message text
112
+ let messageText = q.question;
113
+ const options = [];
114
+
115
+ if (q.options && q.options.length > 0) {
116
+ messageText += '\n';
117
+ q.options.forEach((opt, i) => {
118
+ const key = String(i + 1);
119
+ messageText += `${key}. ${opt.label}`;
120
+ if (opt.description) {
121
+ messageText += ` - ${opt.description}`;
122
+ }
123
+ messageText += '\n';
124
+ options.push({ key, label: opt.label });
125
+ });
126
+ // Add "Other" option
127
+ const otherKey = String(q.options.length + 1);
128
+ messageText += `${otherKey}. Other (type your answer)`;
129
+ options.push({ key: otherKey, label: 'Other' });
130
+ }
131
+
132
+ // Store pending prompt in format webhook expects
133
+ const pendingPrompt = {
134
+ id: promptId,
135
+ timestamp: new Date().toISOString(),
136
+ message: q.question,
137
+ options: options,
138
+ type: options.length > 0 ? 'options' : 'freeform',
139
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
140
+ };
141
+ savePendingPrompt(pendingPrompt);
142
+
143
+ return {
144
+ type: 'custom',
145
+ title: 'Claude needs your input',
146
+ message: messageText,
147
+ promptId: promptId,
148
+ isQuestion: true,
149
+ };
150
+ }
151
+
33
152
  // Check for PR creation
34
153
  if (toolName === 'Bash') {
35
154
  const cmd = toolInput?.command || '';
@@ -73,30 +192,71 @@ function shouldNotify(toolName, toolInput, output) {
73
192
  return null;
74
193
  }
75
194
 
76
- function sendNotification(notification) {
77
- const config = loadConfig();
195
+ function getChannelNumbers(config) {
196
+ const channel = config.channel || 'whatsapp';
78
197
 
79
- if (
80
- !config.accountSid ||
81
- !config.authToken ||
82
- !config.fromNumber ||
83
- !config.toNumber
84
- ) {
85
- // Try env vars
86
- const sid = process.env.TWILIO_ACCOUNT_SID;
87
- const token = process.env.TWILIO_AUTH_TOKEN;
88
- const from = process.env.TWILIO_FROM_NUMBER;
89
- const to = process.env.TWILIO_TO_NUMBER;
90
-
91
- if (!sid || !token || !from || !to) {
92
- console.error('[notify-hook] Missing Twilio credentials');
93
- return;
198
+ if (channel === 'whatsapp') {
199
+ const from = config.whatsappFromNumber || config.fromNumber;
200
+ const to = config.whatsappToNumber || config.toNumber;
201
+ if (from && to) {
202
+ return {
203
+ from: from.startsWith('whatsapp:') ? from : `whatsapp:${from}`,
204
+ to: to.startsWith('whatsapp:') ? to : `whatsapp:${to}`,
205
+ channel: 'whatsapp',
206
+ };
94
207
  }
208
+ }
95
209
 
96
- config.accountSid = sid;
97
- config.authToken = token;
98
- config.fromNumber = from;
99
- config.toNumber = to;
210
+ // Fallback to SMS
211
+ const from = config.smsFromNumber || config.fromNumber;
212
+ const to = config.smsToNumber || config.toNumber;
213
+ if (from && to) {
214
+ return { from, to, channel: 'sms' };
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ function sendNotification(notification) {
221
+ let config = loadConfig();
222
+
223
+ // Apply env vars
224
+ config.accountSid = config.accountSid || process.env.TWILIO_ACCOUNT_SID;
225
+ config.authToken = config.authToken || process.env.TWILIO_AUTH_TOKEN;
226
+ config.channel = config.channel || process.env.TWILIO_CHANNEL || 'whatsapp';
227
+
228
+ // WhatsApp numbers
229
+ config.whatsappFromNumber =
230
+ config.whatsappFromNumber || process.env.TWILIO_WHATSAPP_FROM;
231
+ config.whatsappToNumber =
232
+ config.whatsappToNumber || process.env.TWILIO_WHATSAPP_TO;
233
+
234
+ // SMS numbers (fallback)
235
+ config.smsFromNumber =
236
+ config.smsFromNumber ||
237
+ process.env.TWILIO_SMS_FROM ||
238
+ process.env.TWILIO_FROM_NUMBER;
239
+ config.smsToNumber =
240
+ config.smsToNumber ||
241
+ process.env.TWILIO_SMS_TO ||
242
+ process.env.TWILIO_TO_NUMBER;
243
+
244
+ // Legacy support
245
+ config.fromNumber = config.fromNumber || process.env.TWILIO_FROM_NUMBER;
246
+ config.toNumber = config.toNumber || process.env.TWILIO_TO_NUMBER;
247
+
248
+ if (!config.accountSid || !config.authToken) {
249
+ console.error('[notify-hook] Missing Twilio credentials');
250
+ return;
251
+ }
252
+
253
+ const numbers = getChannelNumbers(config);
254
+ if (!numbers) {
255
+ console.error(
256
+ '[notify-hook] Missing phone numbers for channel:',
257
+ config.channel
258
+ );
259
+ return;
100
260
  }
101
261
 
102
262
  let message = `${notification.title}\n\n${notification.message}`;
@@ -109,9 +269,22 @@ function sendNotification(notification) {
109
269
  message += '\nReply with number to select';
110
270
  }
111
271
 
272
+ // For questions, add reply instruction
273
+ if (notification.isQuestion) {
274
+ message += '\n\nReply with your choice number or type your answer.';
275
+ if (notification.promptId) {
276
+ message += `\n[ID: ${notification.promptId}]`;
277
+ }
278
+ }
279
+
280
+ // Add session link if available
281
+ if (notification.sessionId) {
282
+ message += `\n\nSession: https://claude.ai/share/${notification.sessionId}`;
283
+ }
284
+
112
285
  const postData = new URLSearchParams({
113
- From: config.fromNumber,
114
- To: config.toNumber,
286
+ From: numbers.from,
287
+ To: numbers.to,
115
288
  Body: message,
116
289
  }).toString();
117
290
 
@@ -132,11 +305,23 @@ function sendNotification(notification) {
132
305
  };
133
306
 
134
307
  const req = https.request(options, (res) => {
135
- if (res.statusCode === 201) {
136
- console.error(`[notify-hook] Sent: ${notification.title}`);
137
- } else {
138
- console.error(`[notify-hook] Failed: ${res.statusCode}`);
139
- }
308
+ let body = '';
309
+ res.on('data', (chunk) => (body += chunk));
310
+ res.on('end', () => {
311
+ if (res.statusCode === 201) {
312
+ console.error(
313
+ `[notify-hook] Sent via ${numbers.channel}: ${notification.title}`
314
+ );
315
+ logDebug('MESSAGE_SENT', {
316
+ channel: numbers.channel,
317
+ title: notification.title,
318
+ promptId: notification.promptId,
319
+ });
320
+ } else {
321
+ console.error(`[notify-hook] Failed (${res.statusCode}): ${body}`);
322
+ logDebug('MESSAGE_FAILED', { status: res.statusCode, error: body });
323
+ }
324
+ });
140
325
  });
141
326
 
142
327
  req.on('error', (e) => {
@@ -156,15 +341,19 @@ process.stdin.on('end', () => {
156
341
  const hookData = JSON.parse(input);
157
342
  const { tool_name, tool_input, tool_output } = hookData;
158
343
 
344
+ logDebug('PostToolUse', { tool: tool_name, session: hookData.session_id });
345
+
159
346
  const notification = shouldNotify(tool_name, tool_input, tool_output);
160
347
 
161
348
  if (notification) {
349
+ notification.sessionId = hookData.session_id;
162
350
  sendNotification(notification);
163
351
  }
164
352
 
165
353
  // Always allow (post-tool hooks don't block)
166
354
  console.log(JSON.stringify({ status: 'ok' }));
167
355
  } catch (err) {
356
+ logDebug('ERROR', err.message);
168
357
  console.error('[notify-hook] Error:', err.message);
169
358
  console.log(JSON.stringify({ status: 'ok' }));
170
359
  }
@@ -139,13 +139,9 @@ process.stdin.on('end', () => {
139
139
 
140
140
  clearLatestResponse();
141
141
 
142
- console.log(
143
- JSON.stringify({
144
- decision: 'allow',
145
- context: context,
146
- user_message: `[SMS Response] User replied: "${latestResponse.response}"`,
147
- })
148
- );
142
+ // Log context to stderr for visibility, allow the tool
143
+ console.error(`[sms-hook] Context: ${JSON.stringify(context)}`);
144
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
149
145
  return;
150
146
  }
151
147
 
@@ -162,24 +158,17 @@ process.stdin.on('end', () => {
162
158
  )
163
159
  .join('\n');
164
160
 
165
- console.log(
166
- JSON.stringify({
167
- decision: 'allow',
168
- context: {
169
- type: 'sms_actions_executed',
170
- results,
171
- },
172
- user_message: `[SMS Actions] Executed queued actions:\n${summary}`,
173
- })
174
- );
161
+ // Log results to stderr for visibility, allow the tool
162
+ console.error(`[sms-hook] Actions summary:\n${summary}`);
163
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
175
164
  return;
176
165
  }
177
166
  }
178
167
 
179
168
  // Default: allow everything
180
- console.log(JSON.stringify({ decision: 'allow' }));
169
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
181
170
  } catch (err) {
182
171
  console.error('[sms-hook] Error:', err.message);
183
- console.log(JSON.stringify({ decision: 'allow' }));
172
+ console.log(JSON.stringify({ permissionDecision: 'allow' }));
184
173
  }
185
174
  });