ai-lens 0.8.5 → 0.8.9

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/.commithash CHANGED
@@ -1 +1 @@
1
- f82f461
1
+ e90eb88
package/cli/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -58,9 +58,39 @@ export function shellEscape(str) {
58
58
  return "'" + str.replace(/'/g, "'\\''") + "'";
59
59
  }
60
60
 
61
- function captureCommand() {
61
+ // Resolve a stable node path that survives version upgrades.
62
+ // process.execPath often points to a versioned path (e.g. /opt/homebrew/Cellar/node/24.10.0/bin/node)
63
+ // that breaks after `brew upgrade node`. We prefer symlinks that point to the current version.
64
+ // We intentionally avoid `|| node` fallback — it was tried and reverted (1dfdd25) because
65
+ // it masks capture failures (re-runs with empty stdin, exits 0).
66
+ function findStableNodePath() {
67
+ // If process.execPath is already a symlink (e.g. /usr/local/bin/node), use it directly
68
+ try {
69
+ if (lstatSync(process.execPath).isSymbolicLink()) return process.execPath;
70
+ } catch {}
71
+
72
+ // Try stable symlinks that survive version upgrades
73
+ const candidates = [
74
+ '/opt/homebrew/bin/node', // Homebrew (macOS ARM)
75
+ '/usr/local/bin/node', // Homebrew (macOS x86), manual installs
76
+ ];
77
+ for (const p of candidates) {
78
+ try {
79
+ if (existsSync(p)) return p;
80
+ } catch {}
81
+ }
82
+
83
+ // Fallback: rely on PATH at hook execution time
84
+ return '/usr/bin/env node';
85
+ }
86
+
87
+ export function captureCommand() {
88
+ const nodePath = findStableNodePath();
62
89
  const escaped = shellEscape(CAPTURE_PATH);
63
- return `${shellEscape(process.execPath)} ${escaped}`;
90
+ // /usr/bin/env doesn't need shell-escaping, but named paths do
91
+ return nodePath === '/usr/bin/env node'
92
+ ? `/usr/bin/env node ${escaped}`
93
+ : `${shellEscape(nodePath)} ${escaped}`;
64
94
  }
65
95
 
66
96
  // ---------------------------------------------------------------------------
package/cli/init.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { execSync } from 'node:child_process';
2
+ import { execSync, spawn } from 'node:child_process';
3
3
  import { existsSync, copyFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { homedir } from 'node:os';
@@ -10,6 +10,7 @@ import {
10
10
  heading, detail, blank, getLogPath,
11
11
  } from './logger.js';
12
12
  import { getGitIdentity } from '../client/config.js';
13
+ import { migrateIfNeeded } from '../client/sender.js';
13
14
  import {
14
15
  CAPTURE_PATH, detectInstalledTools,
15
16
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan,
@@ -393,6 +394,28 @@ export default async function init() {
393
394
  error(` Failed to install client files: ${err.message}`);
394
395
  return;
395
396
  }
397
+
398
+ // Migrate legacy queue.jsonl → spool format (idempotent)
399
+ try {
400
+ const { migrated, errors: migErrors } = migrateIfNeeded();
401
+ if (migrated > 0) {
402
+ success(` Migrated ${migrated} queued events to spool format`);
403
+ // Flush migrated events immediately — without this, they sit in pending/
404
+ // until the next capture event (which may never come if the user is done coding).
405
+ const senderPath = join(homedir(), '.ai-lens', 'client', 'sender.js');
406
+ if (existsSync(senderPath)) {
407
+ try {
408
+ const s = spawn(process.execPath, [senderPath], { detached: true, stdio: 'ignore' });
409
+ s.unref();
410
+ } catch { /* best-effort — sender will run on next capture */ }
411
+ }
412
+ }
413
+ if (migErrors.length > 0) {
414
+ warn(` Migration warnings: ${migErrors.slice(0, 3).join('; ')}${migErrors.length > 3 ? ` (+${migErrors.length - 3} more)` : ''}`);
415
+ }
416
+ } catch (err) {
417
+ warn(` Migration skipped: ${err.message}`);
418
+ }
396
419
  blank();
397
420
 
398
421
  // Authentication
@@ -496,7 +519,7 @@ export default async function init() {
496
519
  // Confirm
497
520
  if (!auto) {
498
521
  const answer = await ask('Proceed? [Y/n] ');
499
- if (answer && answer.toLowerCase() !== 'y') {
522
+ if (answer && !['y', 'yes'].includes(answer.toLowerCase())) {
500
523
  info('Aborted.');
501
524
  return;
502
525
  }
@@ -541,10 +564,14 @@ export default async function init() {
541
564
  }
542
565
  }
543
566
 
544
- // Clean up legacy hook locations only AFTER new hooks are verified
545
- for (const { tool } of analyses) {
546
- for (const lr of cleanupLegacyHooks(tool)) {
547
- success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
567
+ // Clean up legacy hook locations only AFTER new hooks are verified.
568
+ // Without this guard, a failed write would delete working legacy hooks,
569
+ // leaving the user with no hooks at all.
570
+ if (!verifyFailed) {
571
+ for (const { tool } of analyses) {
572
+ for (const lr of cleanupLegacyHooks(tool)) {
573
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
574
+ }
548
575
  }
549
576
  }
550
577
  blank();
@@ -566,7 +593,7 @@ export default async function init() {
566
593
 
567
594
  // MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
568
595
  const mcpUrl = `${serverUrl}/mcp`;
569
- const setupMcp = auto ? !!flags.mcpScope : !flags.noMcp;
596
+ const setupMcp = !flags.noMcp;
570
597
 
571
598
  // Claude Code MCP
572
599
  const claudeDir = join(homedir(), '.claude');
package/cli/remove.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { unlinkSync, existsSync, rmSync } from 'node:fs';
2
+ import { unlinkSync, existsSync, rmSync, readdirSync } from 'node:fs';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
@@ -63,8 +63,8 @@ export default async function remove() {
63
63
  blank();
64
64
 
65
65
  // Confirm
66
- const answer = await ask('Proceed? [Y/n] ');
67
- if (answer && answer.toLowerCase() !== 'y') {
66
+ const answer = await ask('Proceed? [y/n] ');
67
+ if (answer && !['y', 'yes'].includes(answer.toLowerCase().trim())) {
68
68
  info('Aborted.');
69
69
  return;
70
70
  }
@@ -146,6 +146,7 @@ export default async function remove() {
146
146
  const dataFiles = [
147
147
  'queue.jsonl', 'queue.sending.jsonl', 'config.json',
148
148
  'sender.log', 'capture.log', 'session-paths.json', 'git-remotes.json', 'last-events.json',
149
+ 'storage-version',
149
150
  ];
150
151
  for (const file of dataFiles) {
151
152
  const filePath = join(dataDir, file);
@@ -159,6 +160,34 @@ export default async function remove() {
159
160
  }
160
161
  }
161
162
 
163
+ // Clean up orphaned migration files (.migrating.*)
164
+ try {
165
+ for (const f of readdirSync(dataDir)) {
166
+ if (f.includes('.migrating.')) {
167
+ const p = join(dataDir, f);
168
+ try {
169
+ unlinkSync(p);
170
+ success(` Deleted ~/.ai-lens/${f}`);
171
+ } catch (err) {
172
+ error(` Failed to delete ${f}: ${err.message}`);
173
+ }
174
+ }
175
+ }
176
+ } catch {}
177
+
178
+ // Clear spool directories (v1 storage format)
179
+ for (const dir of ['pending', 'sending', 'dedup', 'session-paths', 'git-remotes']) {
180
+ const dirPath = join(dataDir, dir);
181
+ try {
182
+ if (existsSync(dirPath)) {
183
+ rmSync(dirPath, { recursive: true });
184
+ success(` Deleted ~/.ai-lens/${dir}/`);
185
+ }
186
+ } catch (err) {
187
+ error(` Failed to delete ${dir}/: ${err.message}`);
188
+ }
189
+ }
190
+
162
191
  // Remove ~/.ai-lens/ if empty
163
192
  try {
164
193
  rmSync(dataDir, { recursive: false });
package/cli/status.js CHANGED
@@ -1,10 +1,10 @@
1
- import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync, writeFileSync, readdirSync } from 'node:fs';
2
2
  import { execSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
 
6
6
  import { getVersionInfo, readLensConfig, detectInstalledTools, analyzeToolHooks, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
7
- import { DATA_DIR, QUEUE_PATH, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
7
+ import { DATA_DIR, PENDING_DIR, SENDING_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
8
8
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
9
9
 
10
10
  // ANSI helpers
@@ -73,42 +73,80 @@ function extractHookCommand(tool) {
73
73
  }
74
74
 
75
75
  function checkCaptureRun(installedTools) {
76
- let command = null;
76
+ // Collect commands from ALL installed tools — not just the first found (Issues 8-9).
77
+ // If Claude Code works but Cursor is broken, both must be tested to surface the failure.
78
+ const toolCommands = [];
77
79
  for (const tool of installedTools) {
78
- command = extractHookCommand(tool);
79
- if (command) break;
80
+ const command = extractHookCommand(tool);
81
+ if (command) toolCommands.push({ name: tool.name, command });
80
82
  }
81
83
 
82
- if (!command) {
84
+ if (toolCommands.length === 0) {
83
85
  return { ok: null, summary: 'no hook command found', detail: 'Could not find an AI Lens hook command in any tool config' };
84
86
  }
85
87
 
86
- try {
88
+ const toolResults = [];
89
+ for (const { name, command } of toolCommands) {
90
+ // Unique session_id per tool run so capture.log entries can be matched precisely (Issue 10).
91
+ const testSessionId = 'status-check-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
87
92
  const testEvent = JSON.stringify({
88
93
  hook_event_name: 'Stop',
89
- session_id: 'status-check-' + Date.now(),
94
+ session_id: testSessionId,
90
95
  stop_reason: 'test',
91
96
  });
92
97
  const testCmd = `echo '${testEvent.replace(/'/g, "'\\''")}' | ${command}`;
93
- execSync(testCmd, {
94
- encoding: 'utf-8',
95
- timeout: 10_000,
96
- stdio: ['pipe', 'pipe', 'pipe'],
97
- env: { ...process.env, AI_LENS_PROJECTS: '/ai-lens-status-check-nonexistent' },
98
- });
99
- return {
100
- ok: true,
101
- summary: 'capture runs OK',
102
- detail: `Ran: ${testCmd}\nResult: exit 0`,
103
- };
104
- } catch (err) {
105
- const stderr = err.stderr?.trim() || err.message;
106
- return {
107
- ok: false,
108
- summary: 'capture failed',
109
- detail: `Ran: echo '{}' | ${command}\nExit code: ${err.status}\nError: ${stderr}`,
110
- };
98
+ let exitOk = false;
99
+ let exitDetail = '';
100
+ try {
101
+ execSync(testCmd, {
102
+ encoding: 'utf-8',
103
+ timeout: 10_000,
104
+ stdio: ['pipe', 'pipe', 'pipe'],
105
+ env: { ...process.env, AI_LENS_PROJECTS: '/ai-lens-status-check-nonexistent' },
106
+ });
107
+ exitOk = true;
108
+ exitDetail = 'exit 0';
109
+ } catch (err) {
110
+ const stderr = err.stderr?.trim() || err.message;
111
+ exitDetail = `Exit code: ${err.status}\nError: ${stderr}`;
112
+ }
113
+
114
+ // Check capture.log for what actually happened to this test event (Issue 10).
115
+ // Exit 0 only proves capture didn't crash — the log entry shows whether it was
116
+ // properly processed (project_filter/no_email = expected) vs silently failed.
117
+ let captureNote = '';
118
+ try {
119
+ if (existsSync(CAPTURE_LOG_PATH)) {
120
+ const logLines = readFileSync(CAPTURE_LOG_PATH, 'utf-8').split('\n').filter(Boolean);
121
+ for (let i = logLines.length - 1; i >= 0; i--) {
122
+ try {
123
+ const entry = JSON.parse(logLines[i]);
124
+ if (entry.session_id === testSessionId) {
125
+ captureNote = entry.reason || entry.msg || 'unknown';
126
+ break;
127
+ }
128
+ } catch { /* skip unparseable lines */ }
129
+ }
130
+ }
131
+ } catch { /* best effort */ }
132
+
133
+ toolResults.push({ name, ok: exitOk, cmd: testCmd, exitDetail, captureNote });
111
134
  }
135
+
136
+ const allOk = toolResults.every(r => r.ok);
137
+ const failedTools = toolResults.filter(r => !r.ok).map(r => r.name);
138
+ const summaryParts = toolResults.map(r => {
139
+ const status = r.ok ? 'OK' : 'FAILED';
140
+ return r.captureNote ? `${r.name}: ${status} (${r.captureNote})` : `${r.name}: ${status}`;
141
+ });
142
+ const summary = allOk
143
+ ? `capture runs OK (${summaryParts.join(', ')})`
144
+ : `capture failed for: ${failedTools.join(', ')}`;
145
+ const detail = toolResults.map(r =>
146
+ `[${r.name}]\n Ran: ${r.cmd}\n Result: ${r.exitDetail}${r.captureNote ? `\n Capture log: ${r.captureNote}` : ''}`
147
+ ).join('\n\n');
148
+
149
+ return { ok: allOk, summary, detail };
112
150
  }
113
151
 
114
152
  function checkVersion() {
@@ -211,72 +249,45 @@ function checkHooks(tool) {
211
249
  }
212
250
 
213
251
  function checkQueue() {
214
- if (!existsSync(QUEUE_PATH)) {
215
- return { ok: true, summary: 'empty (0 events)', detail: 'Queue file does not exist (no pending events)' };
216
- }
252
+ let pendingCount = 0;
253
+ let sendingCount = 0;
217
254
 
218
- let lines;
219
255
  try {
220
- lines = readFileSync(QUEUE_PATH, 'utf-8').split('\n').filter(Boolean);
256
+ if (existsSync(PENDING_DIR)) {
257
+ pendingCount = readdirSync(PENDING_DIR).filter(f => f.endsWith('.json')).length;
258
+ }
221
259
  } catch (err) {
222
- return { ok: false, summary: `error reading queue: ${err.message}`, detail: `Error: ${err.message}` };
260
+ return { ok: false, summary: `error reading pending dir: ${err.message}`, detail: `Error: ${err.message}` };
223
261
  }
224
262
 
225
- const count = lines.length;
226
- const size = statSync(QUEUE_PATH).size;
227
- const sizeStr = size < 1024 ? `${size}B` : `${(size / 1024).toFixed(1)}KB`;
228
-
229
- let detail = `Queue: ${QUEUE_PATH}\nEvents: ${count}\nSize: ${sizeStr}`;
230
-
231
- // Show last 3 events (truncated)
232
- const last3 = lines.slice(-3);
233
- if (last3.length > 0) {
234
- detail += '\n\nLast events:';
235
- for (const line of last3) {
236
- try {
237
- const evt = JSON.parse(line);
238
- detail += `\n ${evt.type || 'unknown'} @ ${evt.timestamp || '?'} [${evt.session_id?.slice(0, 8) || '?'}]`;
239
- } catch {
240
- detail += `\n (unparseable line, ${line.length} chars)`;
241
- }
263
+ try {
264
+ if (existsSync(SENDING_DIR)) {
265
+ sendingCount = readdirSync(SENDING_DIR).filter(f => f.endsWith('.json')).length;
242
266
  }
243
- }
267
+ } catch { /* best effort */ }
244
268
 
245
- // Extract timestamp range from queued events
246
- let timeRange = '';
247
- if (count > 0) {
248
- const timestamps = [];
249
- for (const line of lines) {
250
- try { const e = JSON.parse(line); if (e.timestamp) timestamps.push(e.timestamp); } catch {}
251
- }
252
- if (timestamps.length > 0) {
253
- const fmt = (ts) => {
254
- const d = new Date(ts);
255
- if (isNaN(d)) return ts;
256
- const now = new Date();
257
- const diffMs = now - d;
258
- if (diffMs < 60_000) return `${Math.round(diffMs / 1000)}s ago`;
259
- if (diffMs < 3600_000) return `${Math.round(diffMs / 60_000)}m ago`;
260
- if (diffMs < 86400_000) return `${Math.round(diffMs / 3600_000)}h ago`;
261
- return `${Math.round(diffMs / 86400_000)}d ago`;
262
- };
263
- timestamps.sort();
264
- if (timestamps.length === 1 || timestamps[0] === timestamps[timestamps.length - 1]) {
265
- timeRange = `, ${fmt(timestamps[0])}`;
266
- } else {
267
- timeRange = `, ${fmt(timestamps[0])}–${fmt(timestamps[timestamps.length - 1])}`;
269
+ // Show last 3 pending events (read individual files)
270
+ let detail = `Pending dir: ${PENDING_DIR}\nPending: ${pendingCount} events\nSending: ${sendingCount} events (in-flight)`;
271
+ if (pendingCount > 0) {
272
+ try {
273
+ const files = readdirSync(PENDING_DIR).filter(f => f.endsWith('.json')).sort().slice(-3);
274
+ detail += '\n\nLast pending events:';
275
+ for (const file of files) {
276
+ try {
277
+ const evt = JSON.parse(readFileSync(join(PENDING_DIR, file), 'utf-8'));
278
+ detail += `\n ${evt.type || 'unknown'} @ ${evt.timestamp || '?'} [${evt.session_id?.slice(0, 8) || '?'}]`;
279
+ } catch {
280
+ detail += `\n (unreadable: ${file})`;
281
+ }
268
282
  }
269
- }
283
+ } catch { /* best effort */ }
270
284
  }
271
285
 
272
- // Queue with events = something may be stuck
273
- const ok = count === 0;
274
- return {
275
- ok,
276
- summary: ok ? 'empty (0 events)' : `${count} events pending (${sizeStr}${timeRange})`,
277
- detail,
278
- lineCount: count,
279
- };
286
+ const ok = pendingCount < 100; // warn if many events are stuck
287
+ const summary = pendingCount === 0 && sendingCount === 0
288
+ ? 'empty (0 events)'
289
+ : `${pendingCount} pending, ${sendingCount} sending`;
290
+ return { ok, summary, detail, lineCount: pendingCount };
280
291
  }
281
292
 
282
293
  function checkSenderLog() {
@@ -291,32 +302,51 @@ function checkSenderLog() {
291
302
  return { ok: false, summary: `error reading log: ${err.message}`, detail: `Error: ${err.message}` };
292
303
  }
293
304
 
294
- const last20 = lines.slice(-20);
305
+ // Aggregate across all log entries for observability
306
+ let sentOk = 0; // total events successfully delivered to server
307
+ let failedCount = 0; // total failed send attempts (connection/auth errors)
308
+ let rollbackCount = 0; // total rollbacks (partial + full) — events re-queued for retry
295
309
  let lastSend = null;
296
310
  let hasErrors = false;
297
311
 
298
- for (const line of last20) {
312
+ for (const line of lines) {
299
313
  try {
300
314
  const entry = JSON.parse(line);
301
- if (entry.msg === 'sent') lastSend = entry.ts;
302
- if (entry.msg === 'failed' || entry.msg === 'error' || entry.msg === 'auth-failed') hasErrors = true;
315
+ if (entry.msg === 'sent') {
316
+ lastSend = entry.ts;
317
+ sentOk += (entry.events || 0);
318
+ }
319
+ if (entry.msg === 'failed' || entry.msg === 'error' || entry.msg === 'auth-failed') {
320
+ hasErrors = true;
321
+ if (entry.msg === 'failed') failedCount++;
322
+ }
323
+ if (entry.msg === 'rollback' || entry.msg === 'partial-rollback') rollbackCount++;
303
324
  } catch { /* non-JSON line */ }
304
325
  }
305
326
 
327
+ const stats = [];
328
+ if (sentOk > 0) stats.push(`${sentOk} events sent`);
329
+ if (failedCount > 0) stats.push(`${failedCount} failed`);
330
+ if (rollbackCount > 0) stats.push(`${rollbackCount} rollbacks`);
331
+
306
332
  let summary;
307
333
  if (lastSend) {
308
334
  summary = `last send ${relativeTime(lastSend)}`;
309
- if (hasErrors) summary += ', recent errors';
335
+ if (stats.length > 0) summary += ` (${stats.join(', ')})`;
336
+ if (hasErrors) summary += ', has errors';
310
337
  } else if (hasErrors) {
311
- summary = 'recent errors in log';
338
+ summary = 'errors in log';
339
+ if (stats.length > 0) summary += ` (${stats.join(', ')})`;
312
340
  } else {
313
341
  summary = `${lines.length} entries`;
342
+ if (stats.length > 0) summary += ` (${stats.join(', ')})`;
314
343
  }
315
344
 
345
+ const last20 = lines.slice(-20);
316
346
  return {
317
347
  ok: !hasErrors,
318
348
  summary,
319
- detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nLast 20 entries:\n${last20.join('\n')}`,
349
+ detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nCounters: sent_ok=${sentOk}, failed=${failedCount}, rollbacks=${rollbackCount}\nLast 20 entries:\n${last20.join('\n')}`,
320
350
  };
321
351
  }
322
352
 
@@ -370,7 +400,7 @@ async function checkServer(serverUrl) {
370
400
  const url = `${serverUrl}/api/health`;
371
401
  const start = Date.now();
372
402
  try {
373
- const res = await fetch(url);
403
+ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
374
404
  const latency = Date.now() - start;
375
405
  const body = await res.text();
376
406
  if (res.ok) {
@@ -403,7 +433,7 @@ async function checkToken(serverUrl, authToken) {
403
433
 
404
434
  const url = `${serverUrl}/api/dashboard/overview`;
405
435
  try {
406
- const res = await fetch(url, { headers: { 'X-Auth-Token': authToken } });
436
+ const res = await fetch(url, { headers: { 'X-Auth-Token': authToken }, signal: AbortSignal.timeout(8000) });
407
437
  if (res.ok) {
408
438
  return { ok: true, summary: 'valid', detail: `Token validation: ${res.status} OK` };
409
439
  }
@@ -443,6 +473,7 @@ async function checkE2e(serverUrl, authToken) {
443
473
  ...(name && { 'X-Developer-Name': name }),
444
474
  },
445
475
  body: JSON.stringify(testEvent),
476
+ signal: AbortSignal.timeout(8000),
446
477
  });
447
478
  const body = await res.json();
448
479
  if (res.ok && body.received >= 1) {
@@ -508,16 +539,23 @@ function buildReport(results, timestamp) {
508
539
  lines.push('');
509
540
  }
510
541
 
511
- // Queue content (up to 100 lines)
542
+ // Queue content (spool: list pending/ files)
512
543
  lines.push(`${'='.repeat(60)}`);
513
- lines.push(`Queue (${QUEUE_PATH}):`);
544
+ lines.push(`Pending queue (${PENDING_DIR}):`);
514
545
  try {
515
- const queueLines = readFileSync(QUEUE_PATH, 'utf-8').split('\n').filter(Boolean);
516
- lines.push(`Total: ${queueLines.length} events`);
517
- for (const ql of queueLines.slice(0, 100)) {
518
- lines.push(ql);
546
+ const pendingFiles = existsSync(PENDING_DIR)
547
+ ? readdirSync(PENDING_DIR).filter(f => f.endsWith('.json')).sort()
548
+ : [];
549
+ lines.push(`Total: ${pendingFiles.length} events`);
550
+ for (const file of pendingFiles.slice(0, 100)) {
551
+ try {
552
+ const evt = JSON.parse(readFileSync(join(PENDING_DIR, file), 'utf-8'));
553
+ lines.push(` ${file}: ${evt.type || 'unknown'} [${evt.session_id?.slice(0, 8) || '?'}]`);
554
+ } catch {
555
+ lines.push(` ${file}: (unreadable)`);
556
+ }
519
557
  }
520
- if (queueLines.length > 100) lines.push(`... (${queueLines.length - 100} more)`);
558
+ if (pendingFiles.length > 100) lines.push(`... (${pendingFiles.length - 100} more)`);
521
559
  } catch {
522
560
  lines.push('(empty or not found)');
523
561
  }
@@ -617,12 +655,12 @@ export default async function status() {
617
655
  }
618
656
  }
619
657
 
658
+ // 7. Queue (before capture test so test event doesn't show as pending)
659
+ printLine('Queue', checkQueue());
660
+
620
661
  // 6b. Smoke-test the hook command
621
662
  printLine('Capture test', checkCaptureRun(installedTools));
622
663
 
623
- // 7. Queue
624
- printLine('Queue', checkQueue());
625
-
626
664
  // 8. Sender log
627
665
  printLine('Sender log', checkSenderLog());
628
666